# Introduction
Overview [#overview]
rtemis A3 provides:
* **Specification**: a language-agnostic JSON-based schema for storing annotated amino acid sequences and language-specific specs for A3 support
* **Implementation**: multi-language API for reading and writing A3 files (R, Python, Julia, TypeScript, Rust)
* **Visualization**: [rtemislive-draw](https://draw.rtemis.org) uses the [@rtemis/a3](https://www.npmjs.com/package/@rtemis/a3) module to provide interactive visualizations of A3 files
A3 Schema [#a3-schema]
The official A3 schema is available on [schema.rtemis.org](https://schema.rtemis.org/a3/v1/schema.json).
You can view it on the [specification](/docs/specification) page.
A3 CLI [#a3-cli]
The [rtemis\_a3 crate](/implementation/rust) provides a command-line interface (CLI) tool that can
validate A3 files and print a summary of their contents.
# Goals
The goal is to provide idiomatic, language-specific implementations of the A3 format across
programming languages commonly used in bioinformatics and computational biology.
Goals:
* Choose class system that provides type safety, and validation
* Create methods that translate between A3 data structures and native data structures
* Provide methods for reading and writing A3 JSON files with 100% fidelity
# Julia: RtemisA3.jl
Julia implementation of the A3 (Amino Acid Annotation) format.
Installation [#installation]
```julia
using Pkg
Pkg.add(url="https://github.com/rtemis-org/a3", subdir="julia/RtemisA3")
```
Or from a local clone:
```julia
Pkg.develop(path="julia/RtemisA3")
```
Usage [#usage]
```julia
using RtemisA3
# Construct an A3 object
a = create_a3(
"MAEPRQEFEVMEDHAGTYGL";
site = Dict(
"catalyticResidues" => Dict("index" => [7, 14], "type" => "activeSite"),
),
ptm = Dict(
"Phosphorylation" => Dict("index" => [3, 9, 17], "type" => ""),
),
region = Dict(
"NtermDomain" => Dict("index" => [[1, 10]], "type" => ""),
),
variant = [
Dict("position" => 5, "from" => "Q", "to" => "K"),
],
metadata = Dict(
"uniprot_id" => "P10636",
"description" => "Microtubule-associated protein tau",
"organism" => "Homo sapiens",
),
)
# Serialize to JSON
json_str = a3_to_json(a; indent=2)
# Parse from JSON
b = a3_from_json(json_str)
# File I/O
write_a3json(a, "output.a3.json")
c = read_a3json("output.a3.json")
# Query
residue_at(a, 1) # 'M'
variants_at(a, 5) # Vector{VariantRecord}
```
Data Model [#data-model]
| Field | Type | Description |
| ------------------------ | -------------------------- | ---------------------------------------------------- |
| `sequence` | `String` | Amino acid sequence (`[A-Z*]`, ≥ 2 chars) |
| `annotations.site` | `Dict{String,SiteEntry}` | Named sets of residue positions |
| `annotations.region` | `Dict{String,RegionEntry}` | Named sets of `[start,end]` ranges |
| `annotations.ptm` | `Dict{String,FlexEntry}` | PTMs (positions or ranges) |
| `annotations.processing` | `Dict{String,FlexEntry}` | Processing events (positions or ranges) |
| `annotations.variant` | `Vector{VariantRecord}` | Sequence variants |
| `metadata` | `A3Metadata` | `uniprot_id`, `description`, `reference`, `organism` |
Validation [#validation]
All inputs are validated in two stages:
1. **Structural** — types, non-empty names, `start < end` for ranges, no overlapping ranges, sequence characters
2. **Contextual** — all positions/ranges within `1..length(sequence)`
Errors raise `A3ValidationError` with a message that includes the field path and a concrete description of the violation.
API Reference [#api-reference]
| Function | Description |
| -------------------------------- | ----------------------------------------- |
| `create_a3(seq; ...)` | Construct and validate an A3 object |
| `a3_from_json(text)` | Parse from a JSON string |
| `a3_to_json(a3; indent)` | Serialize to a JSON string |
| `read_a3json(path)` | Read from a `.json` file |
| `write_a3json(a3, path; indent)` | Write to a `.json` file |
| `residue_at(a3, position)` | Return the residue at a 1-based position |
| `variants_at(a3, position)` | Return all variants at a 1-based position |
Running Tests [#running-tests]
```julia
using Pkg
Pkg.test("RtemisA3")
```
# Python: rtemis.a3
Python implementation of the **Amino Acid Annotation (A3)** format —
a structured JSON format for amino acid sequences with site, region, PTM,
processing, and variant annotations.
Part of the [rtemis-org/a3](https://github.com/rtemis-org/a3) monorepo,
which provides A3 implementations in Python, TypeScript, R, Julia, and Rust.
Installation [#installation]
```bash
pip install rtemis-a3
# or with uv
uv add rtemis-a3
```
Quick Start [#quick-start]
```python
from rtemis.a3 import create_a3
a3 = create_a3(
"MKTAYIAKQR",
site={
"Active site": {"index": [3, 5], "type": "activeSite"},
},
region={
"Repeat 1": {"index": [[1, 4]], "type": ""},
},
ptm={
"Phosphorylation": {"index": [7], "type": ""},
},
variant=[{"position": 3, "from": "K", "to": "R"}],
metadata={
"uniprot_id": "P12345",
"description": "Example protein",
"organism": "Homo sapiens",
},
)
len(a3.sequence) # 10
```
Parsing JSON [#parsing-json]
```python
from rtemis.a3 import a3_from_json, A3ValidationError, A3ParseError
try:
a3 = a3_from_json(json_string)
except A3ValidationError as e:
print(e.errors) # list of Pydantic error dicts with field paths
except A3ParseError as e:
print(e) # malformed JSON
```
File I/O [#file-io]
```python
from rtemis.a3 import read_a3json, write_a3json
a3 = read_a3json("protein.json")
write_a3json(a3, "output.json", indent=2)
```
Serialization [#serialization]
```python
from rtemis.a3 import a3_to_json
json_string = a3_to_json(a3) # compact
json_string = a3_to_json(a3, indent=2) # pretty-printed
```
Wire Format [#wire-format]
```json
{
"sequence": "MKTAYIAKQR",
"annotations": {
"site": { "Active site": { "index": [3, 5], "type": "activeSite" } },
"region": { "Repeat 1": { "index": [[1, 4]], "type": "" } },
"ptm": { "Phospho": { "index": [7], "type": "" } },
"processing": {},
"variant": [{ "position": 3, "from": "K", "to": "R" }]
},
"metadata": {
"uniprot_id": "P12345",
"description": "Example protein",
"reference": "",
"organism": "Homo sapiens"
}
}
```
All five annotation families are always present in output. Each annotation
entry is `{ index, type }` — bare arrays are rejected. Positions are
1-based, sorted, and deduplicated. Ranges are `[start, end]` pairs
(`start < end`), sorted by start; overlapping ranges are rejected.
API [#api]
Construction [#construction]
| Function | Description |
| -------------------------------------------------------------------------- | ------------------------------- |
| `create_a3(sequence, *, site, region, ptm, processing, variant, metadata)` | Build and validate an A3 object |
Queries [#queries]
| Function | Description |
| --------------------------- | ------------------------------------------------------------------- |
| `residue_at(a3, position)` | Residue at a 1-based position; raises `ValueError` if out of bounds |
| `variants_at(a3, position)` | All variant records at a 1-based position |
Serialization / I/O [#serialization--io]
| Function | Description |
| ----------------------------------- | --------------------------------------- |
| `a3_from_json(text)` | Parse a JSON string into an A3 object |
| `a3_to_json(a3, *, indent)` | Serialize an A3 object to a JSON string |
| `read_a3json(path)` | Read an A3 JSON file from disk |
| `write_a3json(a3, path, *, indent)` | Write an A3 object to a JSON file |
Pydantic Model Hierarchy [#pydantic-model-hierarchy]
```
A3
├── sequence: str
├── annotations: A3Annotations
│ ├── site: dict[str, SiteEntry] (position index)
│ ├── region: dict[str, RegionEntry] (range index)
│ ├── ptm: dict[str, FlexEntry] (position or range index)
│ ├── processing: dict[str, FlexEntry] (position or range index)
│ └── variant: list[VariantRecord]
└── metadata: A3Metadata
├── uniprot_id, description, reference, organism
```
All models are immutable (`frozen=True`). Users never construct them directly —
use `create_a3` or `a3_from_json` instead.
License [#license]
[MPL-2.0](https://www.mozilla.org/en-US/MPL/2.0/)
# R: rtemis.a3
R implementation of the **Amino Acid Annotation (A3)** format —
a structured JSON format for amino acid sequences with site, region, PTM,
processing, and variant annotations.
Part of the [rtemis-org/a3](https://github.com/rtemis-org/a3) monorepo,
which provides A3 implementations in R, TypeScript, Python, Julia, and Rust.
Installation [#installation]
```r
# From r-universe
install.packages("rtemis.a3", repos = "https://rtemis-org.r-universe.dev")
```
Quick Start [#quick-start]
```r
library(rtemis.a3)
a3 <- create_A3(
sequence = "MKTAYIAKQR",
site = list(
"Active site" = annotation_position(c(3, 5), type = "activeSite")
),
region = list(
"Repeat 1" = annotation_range(matrix(c(1L, 4L), ncol = 2))
),
ptm = list(
Phosphorylation = annotation_position(c(7))
),
variant = list(
annotation_variant(3, info = list(from = "K", to = "R"))
),
uniprot_id = "P12345",
description = "Example protein",
organism = "Homo sapiens"
)
print(a3)
```
Parsing JSON [#parsing-json]
```r
a3 <- A3from_json("path/to/protein.json")
# or from a JSON string
a3 <- A3from_json(json_string)
```
Serialization [#serialization]
```r
json_string <- to_json(a3)
write_A3json(a3, "path/to/output.json")
a3 <- read_A3json("path/to/protein.json")
```
Wire Format [#wire-format]
```json
{
"sequence": "MKTAYIAKQR",
"annotations": {
"site": { "Active site": { "index": [3, 5], "type": "activeSite" } },
"region": { "Repeat 1": { "index": [[1, 4]], "type": "" } },
"ptm": { "Phospho": { "index": [7], "type": "" } },
"processing": {},
"variant": [{ "position": 3, "from": "K", "to": "R" }]
},
"metadata": {
"uniprot_id": "P12345",
"description": "Example protein",
"reference": "",
"organism": "Homo sapiens"
}
}
```
All five annotation families are always present in output. Each annotation
entry is `{ index, type }` — bare arrays are rejected. Positions are
1-based, sorted, and deduplicated. Ranges are `[start, end]` pairs
(`start < end`), sorted by start; overlapping ranges are rejected.
API [#api]
Construction [#construction]
| Function | Description |
| ------------------------------------------------------------------ | ---------------------------------------------------------- |
| `create_A3(sequence, site, region, ptm, processing, variant, ...)` | Create an A3 object |
| `annotation_position(x, type)` | Create a position-indexed annotation entry |
| `annotation_range(x, type)` | Create a range-indexed annotation entry |
| `annotation_variant(x, info)` | Create a variant annotation |
| `concat(x)` | Concatenate a character vector to a single sequence string |
I/O [#io]
| Function | Description |
| ----------------------- | -------------------------------------------------------- |
| `to_json(x)` | Serialize an A3 object to a JSON string |
| `A3from_json(x)` | Parse a JSON string or pre-parsed list into an A3 object |
| `write_A3json(x, path)` | Write an A3 object to a JSON file |
| `read_A3json(path)` | Read an A3 object from a JSON file |
S7 Class Hierarchy [#s7-class-hierarchy]
```
A3
├── sequence: A3Sequence
├── annotations: A3Annotation
│ ├── site: named list of A3Site (A3Position index)
│ ├── region: named list of A3Region (A3Range index)
│ ├── ptm: named list of A3PTM (A3Index — position or range)
│ ├── processing: named list of A3Processing (A3Index — position or range)
│ └── variant: list of A3Variant
└── metadata: A3Metadata
├── uniprot_id, description, reference, organism
```
License [#license]
[GPL (>= 3)](https://www.gnu.org/licenses/gpl-3.0.html)
# Rust: rtemis_a3
Rust implementation of the **Amino Acid Annotation (A3)** format —
a structured JSON format for amino acid sequences with site, region, PTM,
processing, and variant annotations.
Part of the [rtemis-org/a3](https://github.com/rtemis-org/a3) monorepo,
which provides A3 implementations in Python, TypeScript, R, Julia, and Rust.
Installation [#installation]
Add to your `Cargo.toml`:
```toml
[dependencies]
rtemis-a3 = "0.1"
```
Quick Start [#quick-start]
```rust
use rtemis_a3::{a3_from_json, a3_to_json};
let json = r#"{
"sequence": "MKTAYIAKQR",
"annotations": {
"site": { "Active site": { "index": [3, 5], "type": "activeSite" } },
"region": { "Repeat 1": { "index": [[1, 4]], "type": "" } },
"ptm": { "Phospho": { "index": [7], "type": "" } },
"processing": {},
"variant": [{ "position": 3, "from": "K", "to": "R" }]
},
"metadata": {
"uniprot_id": "P12345",
"description": "Example protein",
"reference": "",
"organism": "Homo sapiens"
}
}"#;
let a3 = a3_from_json(json).unwrap();
println!("{}", a3.sequence.len()); // 10
println!("{}", a3_to_json(&a3, None).unwrap()); // compact JSON
println!("{}", a3_to_json(&a3, Some(2)).unwrap()); // pretty-printed
```
Parsing JSON [#parsing-json]
```rust
use rtemis_a3::{a3_from_json, A3Error};
match a3_from_json(json_string) {
Ok(a3) => { /* use a3 */ }
Err(A3Error::Parse(e)) => eprintln!("Malformed JSON: {e}"),
Err(A3Error::Validate(errs)) => {
for msg in errs {
eprintln!("{msg}");
}
}
}
```
Querying [#querying]
```rust
use rtemis_a3::{residue_at, variants_at};
// 1-based position; returns Option
if let Some(aa) = residue_at(&a3, 3) {
println!("Residue at position 3: {aa}");
}
// All variant records at a position
let vars = variants_at(&a3, 3);
```
Wire Format [#wire-format]
```json
{
"sequence": "MKTAYIAKQR",
"annotations": {
"site": { "Active site": { "index": [3, 5], "type": "activeSite" } },
"region": { "Repeat 1": { "index": [[1, 4]], "type": "" } },
"ptm": { "Phospho": { "index": [7], "type": "" } },
"processing": {},
"variant": [{ "position": 3, "from": "K", "to": "R" }]
},
"metadata": {
"uniprot_id": "P12345",
"description": "Example protein",
"reference": "",
"organism": "Homo sapiens"
}
}
```
All five annotation families are always present in output. Each annotation
entry is `{ index, type }` — bare arrays are rejected. Positions are
1-based, sorted, and deduplicated. Ranges are `[start, end]` pairs
(`start < end`), sorted by start; overlapping ranges are rejected.
API [#api]
Parsing and serialization [#parsing-and-serialization]
| Function | Description |
| -------------------------------------------- | --------------------------------------------------------------- |
| `a3_from_json(text: &str)` | Parse a JSON string into a validated `A3` |
| `a3_to_json(a3: &A3, indent: Option)` | Serialize to JSON; `None` = compact, `Some(n)` = n-space indent |
Queries [#queries]
| Function | Description |
| -------------------------------------------- | ------------------------------------------------------ |
| `residue_at(a3: &A3, position: u32)` | Residue at a 1-based position; `None` if out of bounds |
| `variants_at<'a>(a3: &'a A3, position: u32)` | All variant records at a 1-based position |
Type hierarchy [#type-hierarchy]
```
A3
├── sequence: String
├── annotations: Annotations
│ ├── site: HashMap (position index)
│ ├── region: HashMap (range index)
│ ├── ptm: HashMap (position or range index)
│ ├── processing: HashMap (position or range index)
│ └── variant: Vec
└── metadata: Metadata
├── uniprot_id, description, reference, organism: String
```
`A3Index` is an enum that holds either `Positions(Vec)` or `Ranges(Vec<[u32; 2]>)`,
used as the index type inside `FlexEntry`.
Errors [#errors]
```rust
pub enum A3Error {
Parse(serde_json::Error), // malformed JSON
Validate(Vec), // all A3 rule violations, collected before returning
}
```
All violations are collected before returning — you see every problem at once,
not just the first one.
License [#license]
[MPL-2.0](https://www.mozilla.org/en-US/MPL/2.0/)
# TypeScript: @rtemis/a3
[
](https://www.npmjs.com/package/@rtemis/a3)
TypeScript implementation of the **Amino Acid Annotation (A3)** format —
a structured JSON format for amino acid sequences with site, region, PTM,
processing, and variant annotations.
Part of the [rtemis-org/a3](https://github.com/rtemis-org/a3) monorepo.
Installation [#installation]
```bash
npm install @rtemis/a3
# or
pnpm add @rtemis/a3
```
Quick Start [#quick-start]
```ts
import { A3 } from "@rtemis/a3"
const a3 = new A3({
sequence: "MKTAYIAKQR",
annotations: {
site: {
"Active site": { index: [3, 5], type: "activeSite" },
},
region: {
"Repeat 1": { index: [[1, 4]], type: "" },
},
ptm: {
Phosphorylation: { index: [7], type: "" },
},
processing: {},
variant: [{ position: 3, from: "K", to: "R" }],
},
metadata: {
uniprot_id: "P12345",
description: "Example protein",
reference: "",
organism: "Homo sapiens",
},
})
a3.length // 10
a3.residueAt(1) // "M"
a3.toJSONString() // canonical JSON string
```
Parsing JSON [#parsing-json]
```ts
import { A3, A3ValidationError } from "@rtemis/a3"
try {
const a3 = A3.fromJSONText(jsonString)
} catch (e) {
if (e instanceof A3ValidationError) {
console.error(e.issues) // Zod issue array with field paths
}
}
```
File I/O (Node.js) [#file-io-nodejs]
```ts
import { readJSON, writeJSON } from "@rtemis/a3"
const a3 = await readJSON("./protein.json")
await writeJSON(a3, "./output.json")
```
Wire Format [#wire-format]
```json
{
"sequence": "MKTAYIAKQR",
"annotations": {
"site": { "Active site": { "index": [3, 5], "type": "activeSite" } },
"region": { "Repeat 1": { "index": [[1, 4]], "type": "" } },
"ptm": { "Phospho": { "index": [7], "type": "" } },
"processing": {},
"variant": [{ "position": 3, "from": "K", "to": "R" }]
},
"metadata": {
"uniprot_id": "P12345",
"description": "Example protein",
"reference": "",
"organism": "Homo sapiens"
}
}
```
All five annotation families are always present in output. Each annotation
entry is `{ index, type }` — bare arrays are rejected. Positions are
1-based, sorted, and deduplicated. Ranges are `[start, end]` pairs
(`start < end`), sorted by start; overlapping ranges are rejected.
API [#api]
`new A3(input)` / `A3.fromData(input)` [#new-a3input--a3fromdatainput]
Construct and validate. Throws `A3ValidationError` if input is invalid.
`A3.fromJSONText(text)` [#a3fromjsontexttext]
Parse a JSON string and validate. Throws `A3ParseError` on invalid JSON,
`A3ValidationError` on schema violations.
`a3.length` [#a3length]
Number of residues in the sequence.
`a3.residueAt(position)` [#a3residueatposition]
Return the residue at a 1-based position. Throws `RangeError` if out of bounds.
`a3.variantsAt(position)` [#a3variantsatposition]
Return all variant records at a given 1-based position.
`a3.toData()` [#a3todata]
Return the validated data object (frozen).
`a3.toJSONString(indent?)` [#a3tojsonstringindent]
Serialize to a JSON string. Default indent is 2; pass 0 for compact output.
`JSON.stringify(a3)` [#jsonstringifya3]
Works directly — `toJSON()` is implemented.
Exported Types [#exported-types]
```ts
import type {
A3Data,
MetadataData,
VariantData,
SiteEntryData,
RegionEntryData,
FlexEntryData,
} from "@rtemis/a3"
```
License [#license]
[MPL-2.0](https://www.mozilla.org/en-US/MPL/2.0/)
# rtemislive-draw
[rtemislive-draw](https://draw.rtemis.org) is a Next.js web application built with React,
TypeScript, and WebAssembly.
It provides interactive visualization of tabular and hierarchical data. It includes dedicated
support for A3 data using the [@rtemis/a3](https://www.npmjs.com/package/@rtemis/a3) module.
The application is built to run entirely in your browser:
* When you visit the site, the application is loaded on your browser.
* Any dataset you load on the app stays on your computer: nothing gets uploaded to a server.
* Built-in export preview allows you to set up a pixel-perfect export of your visualization and
download it as an SVG, PNG, or WEBP file.
# Julia
Requirements [#requirements]
* Strict canonical format only — no legacy input accommodation
* Runtime validation via hand-written validators (no external schema library)
* Immutable value objects (`struct`, not `mutable struct`)
* Serialize to JSON via `JSON.jl`
* User-facing API is functional — users never call struct constructors directly
* 100% wire-format compatible with R, TypeScript, and Python implementations
Tooling [#tooling]
* Package manager: `Pkg.jl`
* Test runner: `Test` (stdlib)
* JSON: `JSON.jl ^0.21`
Type Hierarchy [#type-hierarchy]
```julia
struct A3Metadata
uniprot_id::String # default ""
description::String # default ""
reference::String # default ""
organism::String # default ""
end
struct SiteEntry
index::Vector{Int} # sorted, deduplicated positions
type::String # default ""
end
struct RegionEntry
index::Vector{Tuple{Int,Int}} # sorted, overlap-checked ranges
type::String # default ""
end
struct FlexEntry
index::Union{Vector{Int}, Vector{Tuple{Int,Int}}} # geometry inferred from first element
type::String # default ""
end
struct VariantRecord
position::Int
extra::Dict{String,Any} # open extra fields, JSON-compatible values only
end
struct A3Annotations
site::Dict{String,SiteEntry}
region::Dict{String,RegionEntry}
ptm::Dict{String,FlexEntry}
processing::Dict{String,FlexEntry}
variant::Vector{VariantRecord}
end
struct A3
sequence::String
annotations::A3Annotations
metadata::A3Metadata
end
```
All structs are immutable (Julia default). `Base.:(==)` is explicitly defined for
all entry types because Julia's default `==` for structs with mutable fields
(`Vector`, `Dict`) falls back to identity (`===`).
Struct Details [#struct-details]
`SiteEntry` [#siteentry]
* `index`: validated by `validate_positions` — checks all elements are positive
integers, then calls `sort_dedup()`.
* `type`: string, defaults to `""`.
`RegionEntry` [#regionentry]
* `index`: validated by `validate_ranges` — checks each element is a 2-element
vector of positive integers with `start < end`, sorts via `sort_ranges()`, then
calls `check_no_overlap()`.
* `type`: string, defaults to `""`.
`FlexEntry` [#flexentry]
* `index`: validated by `validate_flex_index` — infers geometry from the first
element:
* First element is `AbstractVector` → ranges path (same checks as `RegionEntry`)
* First element is an integer → positions path (same checks as `SiteEntry`)
* Empty array → returned as `Vector{Int}()`
* Mixed geometry is rejected by the type system.
`VariantRecord` [#variantrecord]
* `position`: required positive integer.
* `extra`: all keys from the raw dict except `"position"`, validated by
`is_json_compatible()`. Functions, closures, and other non-JSON types are rejected.
`A3Annotations` [#a3annotations]
* Unknown annotation families are rejected at parse time.
* Empty families default to empty `Dict` / `Vector` when absent from input.
* All annotation names (dict keys) must be non-empty strings.
`A3Metadata` [#a3metadata]
* Unknown metadata fields are rejected at parse time.
* All four fields default to `""`.
`A3` [#a3]
* Unknown top-level keys are rejected at parse time.
* `sequence` is validated by `validate_sequence`:
* Must be a string, ≥ 2 characters
* Characters must match `[A-Za-z*]` — normalized to uppercase
* Stage 2 bounds check (`validate_bounds`) runs after structural validation.
Normalization Helpers (`normalize.jl`) [#normalization-helpers-normalizejl]
Pure functions used inside validators:
```julia
sort_dedup(v::Vector{Int}) -> Vector{Int}
# Deduplicate and sort ascending: sort(unique(v))
sort_ranges(v::Vector{Tuple{Int,Int}}) -> Vector{Tuple{Int,Int}}
# Sort by start, then end for ties. No merging.
check_no_overlap(ranges::Vector{Tuple{Int,Int}}, path::String) -> nothing
# Throws A3ValidationError if any consecutive pair overlaps (curr_start <= prev_end).
# Adjacent ranges (curr_start = prev_end + 1) are permitted.
is_json_compatible(v) -> Bool
# Accepts: nothing (null), Bool, Number, AbstractString, AbstractVector, AbstractDict
# (with AbstractString keys).
# Rejects: functions, closures, other Julia objects.
```
Validation (`validate.jl`) [#validation-validatejl]
All parsing and validation is performed by hand-written functions. Two stages:
Stage 1 — Structural [#stage-1--structural]
Entry point is `A3(raw::AbstractDict)` (outer constructor):
* Rejects unknown top-level keys
* Calls `validate_sequence`, `parse_annotations`, `parse_metadata`
* `parse_annotations` / `parse_metadata` reject unknown keys and delegate to
entry-level parsers (`parse_site_entry`, `parse_region_entry`,
`parse_flex_entry`, `parse_variant`)
* Entry parsers call `_parse_entry_base` which rejects bare arrays and unknown
entry-level keys (only `"index"` and `"type"` are allowed)
Stage 2 — Contextual [#stage-2--contextual]
`validate_bounds(seq, annotations)` runs after all structural validation:
* All `site` positions satisfy `1 <= pos <= length(seq)`
* All `region` range endpoints satisfy the same
* All `ptm` and `processing` positions and range endpoints satisfy the same
* All `variant` positions satisfy the same
Error messages include the full field path and concrete bounds, e.g.:
`"annotations.site.bad.index[1]: position 100 is out of bounds for sequence of length 6 (must be 1-6)"`.
Public API (`api.jl`) [#public-api-apijl]
Users never construct structs directly. All entry points are plain functions:
```julia
create_a3(
sequence;
site = nothing,
region = nothing,
ptm = nothing,
processing = nothing,
variant = nothing,
metadata = nothing,
) -> A3
# Build and validate an A3 from raw Dict/Array components (wire format).
# Throws A3ValidationError on invalid input.
residue_at(a3::A3, position::Int) -> Char
# Return the residue at a 1-based position.
# Throws BoundsError if out of bounds.
variants_at(a3::A3, position::Int) -> Vector{VariantRecord}
# Return all variant records at a 1-based position.
```
Serialization and I/O (`io.jl`) [#serialization-and-io-iojl]
```julia
to_dict(a3::A3) -> Dict{String,Any}
# Convert to a plain nested Dict matching the wire format.
# Tuple{Int,Int} ranges are converted to Vector{Int} for JSON serialization.
a3_from_json(text::AbstractString) -> A3
# Parse a JSON string into an A3 object.
# Throws A3ParseError on malformed JSON.
# Throws A3ValidationError on schema violations.
a3_to_json(a3::A3; indent::Union{Int,Nothing}=nothing) -> String
# Serialize an A3 to a canonical JSON string.
read_a3json(path::AbstractString) -> A3
# Read and parse an A3 JSON file from disk.
# Throws A3ParseError on I/O or parse failure.
write_a3json(a3::A3, path::AbstractString; indent::Int=2)
# Write an A3 object to a JSON file on disk.
```
Error Types (`errors.jl`) [#error-types-errorsjl]
```julia
struct A3ValidationError <: Exception
msg::String
end
struct A3ParseError <: Exception
msg::String
end
```
`A3ValidationError` is thrown for schema violations (invalid structure, out-of-bounds
positions, unknown fields). `A3ParseError` is thrown for malformed JSON and file I/O
errors. Both implement `Base.showerror` for readable output.
File Structure [#file-structure]
```
julia/RtemisA3/
src/
RtemisA3.jl # module entry: using, include, export
errors.jl # A3ValidationError, A3ParseError
types.jl # struct definitions + Base.:(==) methods
normalize.jl # sort_dedup, sort_ranges, check_no_overlap, is_json_compatible
validate.jl # parsing, validation, A3(::AbstractDict) outer constructor
io.jl # to_dict, a3_to_json, a3_from_json, read/write_a3json
api.jl # create_a3, residue_at, variants_at
test/
runtests.jl
Project.toml
```
Wire Format [#wire-format]
Strict canonical format. Unknown keys are rejected at all levels. All five annotation
families are always present in serialized output, even when empty. The `type` field
is always present (defaults to `""`):
```json
{
"sequence": "MAEPRQ...",
"annotations": {
"site": {
"Disease_associated_variant": { "index": [4, 5, 14], "type": "" },
"catalyticResidues": { "index": [57, 102], "type": "activeSite" }
},
"region": {
"KXGS": { "index": [[259, 262], [290, 293]], "type": "" }
},
"ptm": {
"Phosphorylation": { "index": [17, 18, 29], "type": "" }
},
"processing": {},
"variant": [
{ "position": 301, "from": "P", "to": "L" }
]
},
"metadata": {
"uniprot_id": "P10636",
"description": "Microtubule-associated protein tau",
"reference": "",
"organism": "Homo sapiens"
}
}
```
`to_dict` produces this structure. `Vector{Tuple{Int,Int}}` ranges are converted
to `Vector{Vector{Int}}` so `JSON.json` serializes them as arrays of arrays.
# A3 Specification
Purpose [#purpose]
A3 is a structured format for annotating amino acid sequences with
site, region, post-translational modification, processing, and variant
information, alongside sequence metadata. It is designed for:
* Exchange between analysis tools and visualization applications
* Long-term storage of curated protein annotation data
* 100% round-trip fidelity through JSON serialization
Wire Format [#wire-format]
JSON is the canonical serialization format. TOML is a secondary target
for human-authoring workflows.
The canonical file extension for A3 JSON files is **`.a3.json`**.
All five annotation families are always present in serialized output,
even when empty. The `type` field is always present on annotation
entries (empty string when unset).
```json
{
"$schema": "https://schema.rtemis.org/v1/schema.json",
"a3_version": "1.0.0",
"sequence": "MAEPRQ...",
"annotations": {
"site": {
"Disease_associated_variant": { "index": [4, 5, 14], "type": "" },
"catalyticResidues": { "index": [57, 102], "type": "activeSite" }
},
"region": {
"KXGS": { "index": [[259, 262], [290, 293]], "type": "" }
},
"ptm": {
"Phosphorylation": { "index": [17, 18, 29], "type": "" }
},
"processing": {},
"variant": [
{ "position": 301, "from": "P", "to": "L" }
]
},
"metadata": {
"uniprot_id": "P10636",
"description": "Microtubule-associated protein tau",
"reference": "",
"organism": "Homo sapiens"
}
}
```
Data Model [#data-model]
```
A3
├── $schema: string (URI)
├── a3_version: string (semver)
├── sequence: string
├── annotations:
│ ├── site: map
│ ├── region: map
│ ├── ptm: map
│ ├── processing: map
│ └── variant: list of { position: integer, [key: string]: any }
└── metadata:
├── uniprot_id: string
├── description: string
├── reference: string
└── organism: string
```
Field Definitions [#field-definitions]
$schema [#schema]
* URI pointing to the JSON Schema for this version of A3
* Fixed value: `"https://schema.rtemis.org/v1/schema.json"`
* Optional on input; always present in serialized output
a3_version [#a3_version]
* Semantic version string identifying the A3 spec version used
* Fixed value for this spec: `"1.0.0"`
* Optional on input; always present in serialized output
sequence [#sequence]
* Non-empty string; minimum 2 characters
* Characters: `[A-Z*]` — standard IUPAC amino acid codes plus `*` (stop codon)
* Normalization: lowercase input is uppercased on parse
Positions (`integer[]`) [#positions-integer]
An ordered collection of 1-based residue positions.
* All values are positive integers (≥ 1)
* Normalization: sorted ascending, duplicates removed
Ranges (`[integer, integer][]`) [#ranges-integer-integer]
An ordered collection of inclusive `[start, end]` range pairs.
* All values are positive integers (≥ 1)
* Each pair: `start < end` (strict — degenerate single-position ranges
are not permitted; use a position-indexed family instead)
* Normalization: sorted by start position (then end position for ties)
* Overlapping ranges are rejected — two ranges `[a, b]` and `[c, d]` overlap
when `c ≤ b` (after sorting). Adjacent ranges (`c = b + 1`) are permitted
and remain as separate entries.
Annotation families [#annotation-families]
Five fixed families — no others are permitted:
| Family | Index type | Semantics |
| ------------ | ------------------- | ------------------------------------- |
| `site` | positions only | Individual residues of interest |
| `region` | ranges only | Contiguous spans |
| `ptm` | positions or ranges | Post-translational modifications |
| `processing` | positions or ranges | Signal peptides, cleavage, maturation |
| `variant` | (see below) | Sequence variants |
Each entry within `site`, `region`, `ptm`, and `processing` is a named
object with two fields:
* `index` — positions or ranges (as defined above)
* `type` — string label; optional on input, always present in output (default `""`)
Annotation names (map keys) are non-empty strings. No constraint on
characters beyond that.
Bare index arrays (without the `{ index, type }` wrapper) are rejected.
The canonical object form is the only accepted input.
Variant [#variant]
An ordered list (not a named map) of variant records. Each record:
* `position`: required, 1-based positive integer
* All other fields: optional, must be JSON-compatible (no functions,
symbols, class instances, or `undefined`)
Metadata [#metadata]
Four string fields, all optional (default `""`):
* `uniprot_id` — UniProt accession
* `description` — human-readable protein description
* `reference` — citation or URL
* `organism` — species name
Unknown metadata fields are rejected.
Validation [#validation]
Stage 1 — Structural [#stage-1--structural]
Performed field-by-field on raw input:
* `$schema`: string (URI); if present, must equal `"https://schema.rtemis.org/v1/schema.json"`
* `a3_version`: string; if present, must equal `"1.0.0"`
* `sequence`: non-empty, `[A-Za-z*]+` (uppercased on parse), ≥ 2 characters
* Positions: positive integers, sorted, deduplicated
* Ranges: positive integers, `start < end`, sorted, overlaps merged
* Annotation entries: `{ index, type }` objects — bare arrays rejected
* Annotation names: non-empty strings
* Unknown annotation families: rejected
* Variant fields: JSON-compatible
* Metadata fields: strings; unknown keys rejected
* Unknown top-level keys: rejected
Stage 2 — Contextual [#stage-2--contextual]
Performed after all structural validation and normalization, on the
fully-resolved data:
* All site / ptm / processing positions satisfy `1 ≤ pos ≤ length(sequence)`
* All region / ptm / processing range endpoints satisfy the same
* All variant positions satisfy the same
Error messages must include the full field path and a concrete
description of the violation (e.g. `"position 450 is out of bounds for
sequence of length 441 (must be 1–441)"`).
Error Philosophy [#error-philosophy]
Implementations must produce clear, corrective error messages:
* State what is wrong and where (include field path)
* State what is expected (e.g. valid range, accepted characters)
* Do not expose internal implementation details
# Python (Pydantic)
Requirements [#requirements]
* Strict canonical format only — no legacy input accommodation
* Runtime validation via Pydantic v2
* Immutable value objects (`frozen=True` on all models)
* Serialize to JSON
* User-facing API is functional — users never call model constructors directly
* 100% wire-format compatible with R and TypeScript implementations
Tooling [#tooling]
* Package manager: `uv`
* Formatter / linter: `ruff`
* Type checker: `ty`
* Test runner: `pytest`
* Validation: `pydantic >=2`
Model Hierarchy [#model-hierarchy]
```
# Constrained type
Position = Annotated[int, Field(gt=0)] # positive integer, 1-based
# Annotation entry models (internal)
SiteEntry(BaseModel, frozen=True)
index: list[Position] # sorted, deduplicated via field_validator
type: str = ""
RegionEntry(BaseModel, frozen=True)
index: list[tuple[Position, Position]] # sorted, overlap-checked via field_validator
type: str = ""
FlexEntry(BaseModel, frozen=True)
index: list[Position] | list[tuple[Position, Position]] # geometry inferred from first element
type: str = ""
VariantRecord(BaseModel, frozen=True, extra="allow")
position: Position
# extra fields: any JSON-compatible values (checked via model_validator)
# Container models
A3Annotations(BaseModel, frozen=True, extra="forbid")
site: dict[str, SiteEntry] = {}
region: dict[str, RegionEntry] = {}
ptm: dict[str, FlexEntry] = {}
processing: dict[str, FlexEntry] = {}
variant: list[VariantRecord] = []
A3Metadata(BaseModel, frozen=True, extra="forbid")
uniprot_id: str = ""
description: str = ""
reference: str = ""
organism: str = ""
A3(BaseModel, frozen=True, extra="forbid")
sequence: str
annotations: A3Annotations = A3Annotations()
metadata: A3Metadata = A3Metadata()
```
Model Details [#model-details]
`SiteEntry` [#siteentry]
* `index`: `field_validator(mode="before")` calls `sort_dedup()` — sorts ascending and
removes duplicates. All elements must be positive integers (enforced by `Position`
constraint after normalization).
* `type`: plain string, defaults to `""`.
`RegionEntry` [#regionentry]
* `index`: `field_validator(mode="before")` coerces inner lists/tuples to `tuple[int, int]`,
validates `start < end` for each pair, sorts via `sort_ranges()`, then calls
`check_no_overlap()`. All elements must be positive integers.
* `type`: plain string, defaults to `""`.
`FlexEntry` [#flexentry]
* `index`: `field_validator(mode="before")` infers geometry from the first element:
* If the first element is a list/tuple → ranges path (same coercion and checks as
`RegionEntry`)
* If the first element is an integer → positions path (same normalization as
`SiteEntry`)
* Empty list → returned as-is
* Mixed geometry is rejected.
`VariantRecord` [#variantrecord]
* `extra="allow"` — open extra fields accepted.
* `model_validator(mode="after")` checks every extra field with `is_json_compatible()`.
Functions, class instances, sets, bytes, etc. are rejected.
`A3Annotations` [#a3annotations]
* `extra="forbid"` rejects unknown annotation families.
* `model_validator(mode="after")` checks that all annotation names (dict keys) in
`site`, `region`, `ptm`, `processing` are non-empty strings.
`A3Metadata` [#a3metadata]
* `extra="forbid"` rejects unknown metadata fields.
* All four fields default to `""`.
`A3` [#a3]
* `extra="forbid"` rejects unknown top-level keys.
* `field_validator("sequence", mode="before")`:
* Must be a string
* Must be ≥ 2 characters
* Characters must match `[A-Za-z*]` — invalid characters reported explicitly
* Normalized to uppercase
* `model_validator(mode="after")` — stage 2 contextual bounds check (see Validation).
Normalization Helpers (`_normalize.py`) [#normalization-helpers-_normalizepy]
Pure functions used inside Pydantic validators:
```python
sort_dedup(values: list[int]) -> list[int]
# Deduplicate and sort ascending: sorted(set(values))
sort_ranges(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]
# Sort by start, then end for ties. No merging.
check_no_overlap(ranges: list[tuple[int, int]]) -> None
# Raises ValueError if any consecutive pair overlaps (curr_start <= prev_end).
# Adjacent ranges (curr_start = prev_end + 1) are permitted.
is_json_compatible(value: object) -> bool
# Accepts: None, bool, int, float, str, list, dict (string keys).
# Rejects: functions, class instances, sets, bytes, etc.
```
Public API (`api.py`) [#public-api-apipy]
Users never construct models directly. All entry points are plain functions:
```python
create_a3(
sequence: str,
*,
site: dict[str, dict[str, Any]] | None = None,
region: dict[str, dict[str, Any]] | None = None,
ptm: dict[str, dict[str, Any]] | None = None,
processing: dict[str, dict[str, Any]] | None = None,
variant: list[dict[str, Any]] | None = None,
metadata: dict[str, str] | None = None,
) -> A3
# Build and validate an A3 from raw components.
# Raises A3ValidationError on invalid input.
a3_from_json(text: str) -> A3
# Parse a JSON string into an A3 object.
# Raises A3ParseError on malformed JSON.
# Raises A3ValidationError on schema violations.
a3_to_json(a3: A3, *, indent: int | None = None) -> str
# Serialize an A3 to a canonical JSON string.
# Uses model_dump(mode="json") for full round-trip fidelity.
residue_at(a3: A3, position: int) -> str
# Return the residue at a 1-based position.
# Raises ValueError if out of bounds.
variants_at(a3: A3, position: int) -> list[VariantRecord]
# Return all variant records at a 1-based position.
```
Error Classes (`errors.py`) [#error-classes-errorspy]
```python
class A3ValidationError(Exception)
errors: list[dict[str, Any]] # Pydantic ValidationError.errors() output
class A3ParseError(Exception)
# Wraps json.JSONDecodeError and file I/O errors
```
`A3ValidationError.errors` is cast from Pydantic's `list[ErrorDetails]` (a TypedDict)
to `list[dict[str, Any]]` to keep `errors.py` free of pydantic imports.
File Structure [#file-structure]
```
python/rtemis/a3/
src/a3/
_normalize.py # pure normalization helpers
_models.py # Pydantic models (internal — not exported directly)
errors.py # A3ValidationError, A3ParseError
api.py # public functional API
__init__.py # public exports
tests/
test_models.py # model-level tests (internal API)
test_api.py # public API tests
pyproject.toml
uv.lock
```
Wire Format [#wire-format]
Strict canonical format. Unknown keys are rejected at all levels. The `type` field
is always present in output (defaults to `""`):
```json
{
"sequence": "MAEPRQ...",
"annotations": {
"site": {
"Disease_associated_variant": { "index": [4, 5, 14], "type": "" },
"catalyticResidues": { "index": [57, 102], "type": "activeSite" }
},
"region": {
"KXGS": { "index": [[259, 262], [290, 293]], "type": "" }
},
"ptm": {
"Phosphorylation": { "index": [17, 18, 29], "type": "" }
},
"processing": {},
"variant": [
{ "position": 301, "from": "P", "to": "L" }
]
},
"metadata": {
"uniprot_id": "P10636",
"description": "Microtubule-associated protein tau",
"reference": "",
"organism": "Homo sapiens"
}
}
```
`model_dump(mode="json")` produces this format. Tuples in `index` fields are
serialized as JSON arrays, preserving wire-format compatibility.
Validation [#validation]
Stage 1 — Structural (`field_validator(mode="before")` on each model) [#stage-1--structural-field_validatormodebefore-on-each-model]
* `sequence`: non-empty string, `[A-Za-z*]+`, ≥ 2 characters, uppercased
* Positions: positive integers, sorted ascending, deduplicated
* Ranges: inner lists/tuples coerced to `tuple[int, int]`, `start < end`,
sorted, overlap-checked
* `FlexEntry` index: geometry inferred from first element — ranges or positions,
never mixed
* Annotation names: non-empty strings (checked via `model_validator(mode="after")`)
* Unknown annotation families: rejected (`extra="forbid"`)
* Variant extra fields: JSON-compatible (checked via `model_validator(mode="after")`)
* Metadata fields: strings; unknown keys rejected (`extra="forbid"`)
* Unknown top-level keys: rejected (`extra="forbid"`)
Stage 2 — Contextual (`model_validator(mode="after")` on `A3`) [#stage-2--contextual-model_validatormodeafter-on-a3]
Runs after all structural validation and normalization:
* All `site` positions satisfy `1 ≤ pos ≤ len(sequence)`
* All `region` range endpoints satisfy the same
* All `ptm` and `processing` positions and range endpoints satisfy the same
* All `variant` positions satisfy the same
All errors are collected before raising so the full set of violations is reported
at once. Error messages include the full field path and concrete bounds:
`"annotations.site.bad.index: position 100 is out of bounds for sequence of
length 6 (must be 1-6)"`.
# Rust
Requirements [#requirements]
* Strict canonical format only — no legacy input accommodation
* Runtime validation via hand-written two-stage validator
* Immutable value objects (all fields are private after construction; clone to modify)
* Serialize to JSON
* 100% wire-format compatible with R, Python, and TypeScript implementations
Tooling [#tooling]
* Package manager: Cargo
* Formatter / linter: `cargo fmt` / `cargo clippy`
* Test runner: `cargo test`
* Serialization: `serde = { version = "1", features = ["derive"] }` + `serde_json`
* Error types: `thiserror`
Type Hierarchy [#type-hierarchy]
```
// Position type: u32 (1-based; 0 is rejected by validation)
// Annotation entry types
SiteEntry
index: Vec // sorted ascending, deduplicated
kind: String // JSON key: "type" (reserved in Rust); default ""
RegionEntry
index: Vec<[u32; 2]> // [start, end] pairs, sorted by start, non-overlapping
kind: String
A3Index (enum, #[serde(untagged)])
Ranges(Vec<[u32; 2]>) // tried first — more specific
Positions(Vec) // fallback
FlexEntry
index: A3Index
kind: String
VariantRecord
position: u32
extra: HashMap // #[serde(flatten)]
Annotations (#[serde(default, deny_unknown_fields)])
site: HashMap
region: HashMap
ptm: HashMap
processing: HashMap
variant: Vec
Metadata (#[serde(default, deny_unknown_fields)])
uniprot_id: String // default ""
description: String // default ""
reference: String // default ""
organism: String // default ""
A3 (#[serde(deny_unknown_fields)])
sequence: String
annotations: Annotations // #[serde(default)]
metadata: Metadata // #[serde(default)]
```
Type Details [#type-details]
`SiteEntry` [#siteentry]
* `index`: normalized by `normalize_positions` — sorted ascending, deduplicated, all values ≥ 1.
* `kind`: field name for the JSON `"type"` key (`#[serde(rename = "type", default)]`).
`String::default()` is `""`, so absent `"type"` keys deserialize to `""`.
`RegionEntry` [#regionentry]
* `index`: normalized by `normalize_ranges` — each pair satisfies `start < end`, sorted by
start (then end for ties), no overlapping pairs.
`A3Index` [#a3index]
* `#[serde(untagged)]`: serde tries `Ranges` first (requires array-of-arrays), then
`Positions` (array of integers). Union order is significant.
* Named `A3Index` (not `FlexIndex`) to match cross-language naming convention.
* Matched with `match entry.index { A3Index::Positions(p) => ..., A3Index::Ranges(r) => ... }`.
`VariantRecord` [#variantrecord]
* `position` is a named field; all other JSON keys are absorbed by
`#[serde(flatten)] extra: HashMap`.
* `serde_json::Value` can represent any valid JSON value — functions, symbols, and
class instances cannot appear in JSON and are therefore structurally excluded.
`Annotations` [#annotations]
* `#[serde(deny_unknown_fields)]` rejects any key other than the five families.
* `#[serde(default)]` + `#[derive(Default)]` fills all fields with empty collections
when `annotations` is absent from the top-level JSON.
`Metadata` [#metadata]
* `#[serde(deny_unknown_fields)]` rejects unknown metadata keys.
* All four fields default to `""` via `#[serde(default)]` + `String::default()`.
`A3` [#a3]
* `#[serde(deny_unknown_fields)]` rejects unknown top-level keys.
* `annotations` and `metadata` use `#[serde(default)]` so they may be omitted from input.
Normalization Helpers (`normalize.rs`) [#normalization-helpers-normalizers]
Pure functions returning `Result`. The `field` parameter carries the
dot-separated JSON path used in error messages (e.g. `"annotations.site.catalytic"`).
```rust
normalize_positions(positions: Vec, field: &str) -> Result, String>
// 1. Reject any position == 0 (positions are 1-based)
// 2. Sort ascending (sort_unstable)
// 3. Remove consecutive duplicates (dedup)
normalize_ranges(ranges: Vec<[u32; 2]>, field: &str) -> Result, String>
// 1. Reject any endpoint == 0
// 2. Reject any range where start >= end
// 3. Sort by start, then end for ties (sort_unstable_by)
// 4. Reject overlapping pairs: overlap when ranges[i+1][0] <= ranges[i][1]
normalize_sequence(sequence: &str) -> Result
// 1. Uppercase the input (to_uppercase)
// 2. Reject if length < 2
// 3. Reject any character not in [A-Z*]
```
Public API (`lib.rs`) [#public-api-librs]
```rust
// Parse a JSON string into a validated, normalized A3.
// Composes serde_json::from_str (structural) + validate (A3 rules).
// The ? operator propagates serde_json::Error → A3Error::Parse automatically.
a3_from_json(text: &str) -> Result
// Serialize a validated A3 to JSON.
// indent: None → compact (serde_json::to_string)
// Some(n) → n-space indented (PrettyFormatter::with_indent)
a3_to_json(a3: &A3, indent: Option) -> Result
// Return the amino acid character at a 1-based position.
// Returns None if position == 0 or > sequence length.
residue_at(a3: &A3, position: u32) -> Option
// Return references to all variant records at a 1-based position.
// Lifetime 'a ties the returned references to the lifetime of the A3 input.
variants_at<'a>(a3: &'a A3, position: u32) -> Vec<&'a VariantRecord>
```
Error Types (`error.rs`) [#error-types-errorrs]
```rust
#[derive(Debug, thiserror::Error)]
pub enum A3Error {
// Wraps serde_json::Error. #[from] enables automatic conversion via ?.
#[error("Failed to parse JSON: {0}")]
Parse(#[from] serde_json::Error),
// Collects all validation violations before returning.
#[error("A3 validation failed:\n{0:#?}")]
Validate(Vec),
}
```
Validation (`validate.rs`) [#validation-validaters]
Stage 1 — Structural [#stage-1--structural]
`validate(raw: A3) -> Result` iterates every field:
* `sequence`: calls `normalize_sequence`; carries a placeholder on failure so Stage 2 can run
* `site` entries: checks non-empty name, calls `normalize_positions`
* `region` entries: checks non-empty name, calls `normalize_ranges`
* `ptm` / `processing` entries: `normalize_flex_family` helper matches on `A3Index` variant
and dispatches to the appropriate normalizer
* `variant` records: checks `position != 0`
Two mutable closures both capturing `&mut errors` are rejected by the borrow checker
(only one mutable borrow of a binding may be live at a time). Use standalone functions
that take `errors: &mut Vec` as an explicit parameter instead.
Stage 2 — Contextual [#stage-2--contextual]
Runs after all normalization, on the fully resolved data:
* `check_positions_bounds(positions, seq_len, field, errors)` — each position ≤ seq\_len
* `check_ranges_bounds(ranges, seq_len, field, errors)` — each end endpoint ≤ seq\_len
(start is guaranteed ≤ end by Stage 1, so only end needs checking)
* Variant positions: checked inline
Error messages follow the pattern:
`"annotations.site.bad: position 100 is out of bounds for sequence of length 6 (must be 1–6)"`
File Structure [#file-structure]
```
rust/
Cargo.toml
src/
lib.rs // module declarations, public API, re-exports
error.rs // A3Error enum
types.rs // A3, Annotations, Metadata, SiteEntry, RegionEntry,
// FlexEntry, A3Index, VariantRecord
normalize.rs // normalize_positions, normalize_ranges, normalize_sequence
validate.rs // validate, normalize_flex_family,
// check_positions_bounds, check_ranges_bounds
```
Wire Format [#wire-format]
Strict canonical format. Unknown keys are rejected at all levels. The `"type"` field
is always present in output (defaults to `""`):
```json
{
"sequence": "MAEPRQ...",
"annotations": {
"site": {
"Disease_associated_variant": { "index": [4, 5, 14], "type": "" },
"catalyticResidues": { "index": [57, 102], "type": "activeSite" }
},
"region": {
"KXGS": { "index": [[259, 262], [290, 293]], "type": "" }
},
"ptm": {
"Phosphorylation": { "index": [17, 18, 29], "type": "" }
},
"processing": {},
"variant": [
{ "position": 301, "from": "P", "to": "L" }
]
},
"metadata": {
"uniprot_id": "P10636",
"description": "Microtubule-associated protein tau",
"reference": "",
"organism": "Homo sapiens"
}
}
```
`serde_json::to_string` / `serde_json::Serializer` with `PrettyFormatter` produce
this format. `[u32; 2]` arrays serialize as JSON arrays, preserving wire-format
compatibility across all A3 implementations.
# R (S7)
Requirements [#requirements]
* Thorough validation (type checks, bounds checks, etc.)
* Serialize to JSON
* Visualize using echarts in rtemislive-draw Next.js WASM app
* Compatible with popular protein sequence/annotation formats without loss of
information (UniProt, ClinVar, GFF, GTF, GenBank, etc.)
* Fixed annotation categories aligned with UniProt: `site`, `region`, `ptm`,
`processing`, `variant`
* `type` is optional per annotation — not part of the schema, but serialized as
`""` when absent so the field is always present in JSON output (supports manual
editing in the web app)
S7 Class Hierarchy [#s7-class-hierarchy]
```
A3Index (abstract base — pure geometry, no type)
├── A3Position(data: integer())
└── A3Range(data: integer matrix N x 2, colnames = c("start", "end"))
A3Sequence(data: character(1))
A3Feature(type: character(1) = "")
├── A3Site(index: A3Position)
├── A3Region(index: A3Range)
├── A3PTM(index: A3Index)
└── A3Processing(index: A3Index)
A3Variant(position: A3Position, info: named list)
A3Annotation
site: named list of A3Site
region: named list of A3Region
ptm: named list of A3PTM
processing: named list of A3Processing
variant: list of A3Variant
Metadata (abstract base)
└── A3Metadata
uniprot_id: character(1), default ""
description: character(1), default ""
reference: character(1), default ""
organism: character(1), default ""
A3
├── sequence: A3Sequence
├── annotations: A3Annotation
└── metadata: A3Metadata
```
Class Details [#class-details]
A3Sequence [#a3sequence]
Wraps `character(1)` with its own validator.
Validation (stage 1):
* Non-empty string
* Uppercase
* Characters in `[A-Z*]` only
A3Index [#a3index]
Abstract base class for sequence index types. Provides a common type for
`A3PTM` and `A3Processing`, which accept either positions or ranges.
Pure geometry — carries `data` only, no `type` (type lives on `A3Feature`).
A3Position [#a3position]
Sorted unique 1-based positive integer positions. Wraps `integer()`.
Validation (stage 1):
* Elements are positive integers
* Sorted ascending
* No duplicates
A3Range [#a3range]
N x 2 integer matrix of inclusive `[start, end]` range pairs.
Column names: `"start"`, `"end"`.
Structurally parallel to `A3Position` — both are collection types wrapping
native R vectorized data structures.
Validation (stage 1):
* All values are positive integers
* Each row: `start < end`
* Rows sorted by start, then end
Empty: `matrix(integer(), ncol = 2)`.
A3Feature [#a3feature]
Abstract base class for annotation feature types (`A3Site`, `A3Region`,
`A3PTM`, `A3Processing`). Named after the standard bioinformatics term used
by UniProt, GFF, GTF, and GenBank.
Properties:
* `type`: `character(1)`, default `""`. Always serialized in JSON output.
A3Site [#a3site]
Point annotation feature. Index is `A3Position`.
A3Region [#a3region]
Range annotation feature. Index is `A3Range`.
Sites and regions are visualized differently: sites show individual circles
at each residue position; regions show contiguous bands spanning `[start, end]`.
A3PTM [#a3ptm]
Post-translational modification feature. Index is `A3Index` (either
`A3Position` or `A3Range`, never mixed within one entry).
A3Processing [#a3processing]
Sequence processing/maturation feature (e.g. signal peptides, cleavage sites,
mature chains). Index is `A3Index` (either `A3Position` or `A3Range`, never
mixed within one entry).
A3Variant [#a3variant]
Variant record with required 1-based `position` (`A3Position`) and open
JSON-compatible `info` (named list).
A3Annotation [#a3annotation]
Container for the five annotation families. Each named annotation family
is a named list keyed by annotation name:
```
site: named list of A3Site
region: named list of A3Region
ptm: named list of A3PTM
processing: named list of A3Processing
variant: list of A3Variant (ordered, not named)
```
Metadata / A3Metadata [#metadata--a3metadata]
`Metadata` is an abstract base class. `A3Metadata` inherits from it with
fields specific to amino acid annotations. Other data types will have their
own `Metadata` subtypes. A generic Metadata viewer is planned for the web app.
All metadata fields are `character(1)` with default `""`.
A3 [#a3]
Top-level class. Construction validates and normalizes all input.
Wire Format [#wire-format]
Annotation families use a named map. Each entry value is an object with
`index` and `type` fields — canonical form only. Bare arrays are rejected.
`type` is always present in output (empty string when unset):
```json
{
"sequence": "MAEPRQ...",
"annotations": {
"site": {
"Disease_associated_variant": {"index": [4, 5, 14], "type": ""},
"catalyticResidues": {"index": [57, 102], "type": "activeSite"}
},
"region": {
"KXGS": {"index": [[259, 262], [290, 293]], "type": ""}
},
"ptm": {
"Phosphorylation": {"index": [17, 18, 29], "type": ""}
},
"processing": {},
"variant": [
{"position": 301, "from": "P", "to": "L"}
]
},
"metadata": {
"uniprot_id": "P10636",
"description": "Microtubule-associated protein tau",
"reference": "",
"organism": "Homo sapiens"
}
}
```
Serialization [#serialization]
`to_json(x)` — S7 generic [#to_jsonx--s7-generic]
Converts an A3 object to a canonical JSON string. Uses `jsonlite::unbox()`
for scalar fields and preserves arrays for index data.
`A3from_json(x)` — function [#a3from_jsonx--function]
Accepts a JSON string or pre-parsed named list and returns an A3 object.
Each annotation entry must be an object with `index` and `type` fields;
bare arrays are rejected.
`write_A3json` / `read_A3json` [#write_a3json--read_a3json]
File I/O wrappers around `to_json`/`from_json`.
Validation [#validation]
Two-stage validation.
Stage 1 — Structural (each class validates itself) [#stage-1--structural-each-class-validates-itself]
* `A3Position`: elements are integers, positive, sorted ascending, unique
* `A3Range`: integer matrix, positive values, each row `start < end`,
rows sorted
* `A3Sequence`: non-empty, uppercase, characters in `[A-Z*]`
* `A3Feature` subtypes: `type` is `character(1)`
* `A3Variant`: `position` is a positive integer scalar
* `A3Annotations`: each family contains only its allowed feature types;
annotation names are non-empty strings
Stage 2 — Contextual (A3 validates the whole) [#stage-2--contextual-a3-validates-the-whole]
* All positions satisfy `1 <= pos <= nchar(sequence)`
* All range endpoints satisfy the same
* Sequence is valid (handled by `A3Sequence` in stage 1)
Internal classes are not exported. All user-facing construction goes through
`create_A3()` / `A3from_json()`, which runs both stages.
# A3 Specification
Purpose [#purpose]
A3 is a structured format for annotating amino acid sequences with
site, region, post-translational modification, processing, and variant
information, alongside sequence metadata. It is designed for:
* Exchange between analysis tools and visualization applications
* Long-term storage of curated protein annotation data
* 100% round-trip fidelity through JSON serialization
Wire Format [#wire-format]
JSON is the canonical serialization format. TOML is a secondary target
for human-authoring workflows.
The canonical file extension for A3 JSON files is **`.a3.json`**.
All five annotation families are always present in serialized output,
even when empty. The `type` field is always present on annotation
entries (empty string when unset).
Example — MAPT (P10636) [#example--mapt-p10636]
Data Model [#data-model]
```
A3
├── sequence: string
├── annotations:
│ ├── site: map
│ ├── region: map
│ ├── ptm: map
│ ├── processing: map
│ └── variant: list of { position: integer, [key: string]: any }
└── metadata:
├── uniprot_id: string
├── description: string
├── reference: string
└── organism: string
```
Field Definitions [#field-definitions]
sequence [#sequence]
* Non-empty string; minimum 2 characters
* Characters: `[A-Z*]` — standard IUPAC amino acid codes plus `*` (stop codon)
position (`integer[]`) [#position-integer]
An ordered collection of 1-based residue positions.
* All values are positive integers (≥ 1)
range (`[integer, integer][]`) [#range-integer-integer]
An ordered collection of inclusive `[start, end]` range pairs.
* All values are positive integers (≥ 1)
* Each pair: `start < end` (strict — degenerate single-position ranges
are not permitted; use a position-indexed family instead)
* No two ranges may overlap: ranges `[a, b]` and `[c, d]` (where `c > a`)
overlap when `c ≤ b`. Adjacent ranges (`c = b + 1`) are permitted.
Annotation families [#annotation-families]
Five fixed families — no others are permitted:
| Family | Index type | Semantics |
| ------------ | ------------------- | ------------------------------------- |
| `site` | positions only | Individual residues of interest |
| `region` | ranges only | Contiguous spans |
| `ptm` | positions or ranges | Post-translational modifications |
| `processing` | positions or ranges | Signal peptides, cleavage, maturation |
| `variant` | (see below) | Sequence variants |
Each entry within `site`, `region`, `ptm`, and `processing` is a named
object with two fields:
* `index` — positions or ranges (as defined above)
* `type` — string label; optional on input, always present in output (default `""`)
Annotation names (map keys) are non-empty strings. No constraint on
characters beyond that.
Bare index arrays (without the `{ index, type }` wrapper) are not
permitted. The canonical object form is the only accepted input.
variant [#variant]
An ordered list (not a named map) of variant records. Each record:
* `position`: required, 1-based positive integer
* All other fields: optional, must be JSON-compatible (no functions,
symbols, class instances, or `undefined`)
metadata [#metadata]
Four string fields, all optional (default `""`):
* `uniprot_id` — UniProt accession
* `description` — human-readable protein description
* `reference` — citation or URL
* `organism` — species name
Unknown metadata fields are not permitted. Unknown top-level keys are
not permitted.
# Typescript (Zod)
Requirements [#requirements]
* Strict canonical format only — no legacy input accommodation
* Runtime validation via Zod (TypeScript types are compile-time only)
* Immutable value objects (`Object.freeze` at construction)
* Serialize to JSON
* Primary consumer: rtemislive-draw Next.js visualization app
* 100% wire-format compatible with the R implementation
Tooling [#tooling]
* Package manager: `pnpm`
* Formatter / linter: `biome`
* Test runner: `vitest`
* Validation: `zod ^3`
Type Hierarchy [#type-hierarchy]
Types are inferred from Zod schemas — no separate interface/type declarations.
```
// Primitives (Zod schemas → inferred TypeScript types)
PositionSchema → number (positive integer, 1-based)
PositionsSchema → number[] (sorted, deduplicated)
RangeTupleSchema → [number, number] (start <= end)
RangesSchema → [number, number][] (sorted, overlaps merged)
// Annotation entries
SiteEntryData → { index: number[]; type: string }
RegionEntryData → { index: [number, number][]; type: string }
FlexEntryData → { index: number[] | [number, number][]; type: string }
VariantData → { position: number; [key: string]: unknown }
// Top-level
A3Data → {
sequence: string
annotations: {
site: Record
region: Record
ptm: Record
processing: Record
variant: VariantData[]
}
metadata: {
uniprot_id: string // default ""
description: string // default ""
reference: string // default ""
organism: string // default ""
}
}
```
Schema Details [#schema-details]
Sequence [#sequence]
* `z.string()` with `.min(2)`, regex `[A-Za-z*]+`, `.transform(s => s.toUpperCase())`
* Lowercase accepted and normalized to uppercase
* Characters outside `[A-Za-z*]` are rejected
Positions (`PositionsSchema`) [#positions-positionsschema]
* `z.array(z.number().int().min(1))`
* `.transform(sortDedup)` — sorted ascending, duplicates removed
Ranges (`RangesSchema`) [#ranges-rangesschema]
* `z.array(z.tuple([PositionSchema, PositionSchema]).refine(([s, e]) => s < e))`
* `.transform(sortRanges)` — sorted by start (then end for ties)
* `.superRefine(checkNoOverlap)` — rejects if any two consecutive ranges overlap (`curr[0] <= prev[1]`); adjacent ranges (`curr[0] = prev[1] + 1`) are permitted
Annotation entry schemas [#annotation-entry-schemas]
**Site** (`SiteEntrySchema`): `{ index: PositionsSchema, type: z.string().default("") }`
**Region** (`RegionEntrySchema`): `{ index: RangesSchema, type: z.string().default("") }`
**PTM / Processing** (`FlexEntrySchema`):
`{ index: z.union([RangesSchema, PositionsSchema]), type: z.string().default("") }`
Union order is significant: `RangesSchema` is tried first (more specific — requires
2-element tuple elements). Input with scalar number elements falls through to
`PositionsSchema`.
Variant (`VariantSchema`) [#variant-variantschema]
* `z.object({ position: PositionSchema }).catchall(z.unknown())`
* `.refine(isJsonCompatible)` — all fields must be recursively JSON-compatible
Annotation families (`AnnotationsSchema`) [#annotation-families-annotationsschema]
* `z.object({ site, region, ptm, processing, variant }).strict()`
* `.strict()` rejects any key not in `{ site, region, ptm, processing, variant }`
* All families default to `{}` / `[]` when absent
Metadata (`MetadataSchema`) [#metadata-metadataschema]
* `z.object({ uniprot_id, description, reference, organism }).strict()`
* All fields are `z.string().default("")`
Root schema (`A3InputSchema`) [#root-schema-a3inputschema]
* `z.object({ sequence, annotations, metadata }).strict()`
* `.strict()` rejects unknown top-level keys
* `.superRefine(boundsCheck)` — stage 2 contextual validation
Normalization Helpers (`normalize.ts`) [#normalization-helpers-normalizets]
Pure functions used inside Zod transforms:
```ts
sortDedup(arr: readonly number[]): number[]
// Deduplicate and sort ascending
sortRanges(arr: readonly [number, number][]): [number, number][]
// Sort by start (then end for ties); no merging
// Overlap detection is a separate step in RangesSchema
isJsonCompatible(v: unknown): boolean
// Recursive check: null | boolean | number | string | array | plain object
// Rejects: undefined, functions, symbols, class instances
```
`A3` Class [#a3-class]
```ts
class A3 {
readonly #data: A3Data // Object.freeze'd at construction
constructor(input: unknown)
static fromData(data: unknown): A3
static fromJSONText(text: string): A3
static async readJSON(path: string): Promise // via io.ts
get length(): number // sequence length
residueAt(position: number): string // 1-based; throws RangeError
variantsAt(position: number): VariantData[]
toData(): A3Data // frozen reference
toJSON(): A3Data // called by JSON.stringify
toJSONString(indent?: number): string
async writeJSON(path: string, indent?: number): Promise
}
```
`toJSON()` returns the plain data object (not a string), so `JSON.stringify(a3)`
works naturally and produces canonical output.
Error Classes [#error-classes]
```ts
class A3ValidationError extends Error
issues: ZodError["issues"] // full Zod issue list for programmatic inspection
class A3ParseError extends Error
// wraps JSON.parse failures and file I/O errors
```
File Structure [#file-structure]
```
typescript/
src/
normalize.ts // pure normalization helpers
schemas.ts // Zod schemas + exported inferred types
a3.ts // A3 class, A3ValidationError, A3ParseError
io.ts // readJSON / writeJSON (node:fs/promises)
index.ts // public exports
tests/
normalize.test.ts
schemas.test.ts
a3.test.ts
roundtrip.test.ts
```
Wire Format [#wire-format]
Strict canonical format. Unknown keys are rejected at the top level and in
annotation families. The `type` field is always present in output (defaults to `""`).
```json
{
"sequence": "MAEPRQ...",
"annotations": {
"site": {
"Disease_associated_variant": { "index": [4, 5, 14], "type": "" },
"catalyticResidues": { "index": [57, 102], "type": "activeSite" }
},
"region": {
"KXGS": { "index": [[259, 262], [290, 293]], "type": "" }
},
"ptm": {
"Phosphorylation": { "index": [17, 18, 29], "type": "" }
},
"processing": {},
"variant": [
{ "position": 301, "from": "P", "to": "L" }
]
},
"metadata": {
"uniprot_id": "P10636",
"description": "Microtubule-associated protein tau",
"reference": "",
"organism": "Homo sapiens"
}
}
```
Validation [#validation]
Stage 1 — Structural (Zod schemas) [#stage-1--structural-zod-schemas]
* `sequence`: non-empty, `[A-Za-z*]+`, uppercased
* Positions: positive integers, sorted, deduplicated
* Ranges: `start <= end`, sorted, overlaps merged
* Annotation entries: must be `{ index, type }` objects — bare arrays rejected
* Annotation family keys: non-empty strings
* Unknown annotation families: rejected (`.strict()`)
* Variant fields: JSON-compatible
* Metadata fields: strings; unknown keys rejected (`.strict()`)
* Unknown top-level keys: rejected (`.strict()`)
Stage 2 — Contextual (`.superRefine`) [#stage-2--contextual-superrefine]
Runs on the fully normalized data (after all transforms):
* All site / ptm / processing positions satisfy `1 <= pos <= sequence.length`
* All region / ptm / processing range endpoints satisfy the same
* All variant positions satisfy the same
* Error paths include the full field path for precise error messages
# A3 Schema