# 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. a3 cli example
rtemis LambdaMD
# 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 [npm version](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. mapt-draw screenshot # 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