Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

omnist logo

omnist is one canonical data model for JSON, YAML, TOML, XML, and its own native OML, plus a schema language (OSD) to validate and compare shapes over that model. This book is the Rust port’s documentation (the crate is omnist, CLI is omnist-cli).

Start with the Quickstart, then read the user guide for the full model. The CLI reference covers the omnist binary, and each entry under Formats documents one codec’s specific round-trip behavior.

This is the Rust port of omnist. See Python divergences for where the two implementations differ, Conformance against omnist-spec for this port’s real, measured pass/fail/ skip numbers against the upstream spec, and Limitations & stability for the current alpha-status caveats.

Quickstart

[dependencies]
omnist = "0.12.0-alpha"

The shortest possible tour – one round trip, one schema, one validation, one inference. Each snippet below is a trimmed version of a real file under omnist/examples/; run it yourself with cargo run --example <name>.

1. Read and write a document

#![allow(unused)]
fn main() {
use indexmap::IndexMap;
use omnist::document::{Doc, Value};
use omnist::formats::json::{read_json, write_json};

let mut fields = IndexMap::new();
fields.insert("name".to_string(), Value::Str("Ada".to_string()));
fields.insert("age".to_string(), Value::Int(37));
let doc = Doc::of(&Value::Object(fields)).unwrap();

let text = write_json(&doc, Some(2), true, None).unwrap();
// {
//   "name": "Ada",
//   "age": 37
// }

let doc2 = read_json(&text).unwrap();
assert!(doc.eq_doc(&doc2), "round trip must be lossless");
}

2. Validate against a schema

#![allow(unused)]
fn main() {
use omnist::osd::parse_schema;

let schema = parse_schema(
    r#"record Person { "name": string, "age": integer } root Person"#,
).unwrap();
// schema.validate(&doc.root()).ok() == true for the document above
}

3. Infer a schema from example documents

#![allow(unused)]
fn main() {
use omnist::infer::infer;
use omnist::osd::to_osd;

let schema = infer(&samples, "Person").unwrap();
println!("{}", to_osd(&schema, Some(2)).unwrap());
// record Person {
//   "name": string,
//   "age": integer,
//   "tags" [0,]: string,
// }
// root Person
}

That’s it – a Doc, a Schema, validate(), and infer(). From here:

Omnist (Rust) – user guide

Omnist gives you one canonical data model for JSON, YAML, TOML, XML, and its own native OML, and a schema language (OSD) to validate and compare shapes over it. This is the Rust port of omnist; its public types (Doc, Schema, error enums) are not identical to Python’s or TypeScript’s by design, but the model and every format’s observable behavior are the same.

The two ideas

  • A Document ([omnist::document::Doc]) is a tree: a node is either a scalar value or an ordered list of labeled edges. “Many” is a label that repeats, not a field pointing to an array.
  • A Schema ([omnist::schema::Schema]) is built from named record definitions (a closed set of named fields, each with a cardinality). A field’s type is exactly one of seven fixed scalar kinds (optionally nullable, e.g. string?) or a Ref to a named record – never a composition of the two. Refs are how reuse and recursion work.

Documents

[Doc::of] builds a Doc from a [Value] (this crate’s plain-value input type, analogous to a parsed JSON value). An object becomes an edge list; a key whose value is a Value::Array expands into one edge per item (a repeated label) – there is no separate array node type.

#![allow(unused)]
fn main() {
use indexmap::IndexMap;
use omnist::document::{Doc, Value};

let mut fields = IndexMap::new();
fields.insert("name".to_string(), Value::Str("Ann".to_string()));
fields.insert(
    "tag".to_string(),
    Value::Array(vec![Value::Str("x".into()), Value::Str("y".into())]),
);
let doc = Doc::of(&Value::Object(fields)).unwrap();
let root = doc.root();

root.labels();                 // ["name", "tag"]
root.count("tag");              // 2 -- "tag" is a repeated label (an array)
root.get_one("name").unwrap();  // a Cursor whose .value() is Scalar::Str("Ann")
root.get("tag");                 // both "tag" cursors, in order
}

Doc::to_grouped() projects a Doc back to a JSON-shaped [Value] (same- label edges become an array); Doc::to_raw()/Doc::from_raw() go through [RawNode], the interleaving-preserving representation OML and XML need (see formats/oml.md).

OML – the native format

OML is omnist’s own serialization format: every Document round-trips through it exactly, with no adjustment ever needed (unlike JSON/YAML/TOML/ XML, which each have some lossy corner – see the per-format pages).

#![allow(unused)]
fn main() {
use omnist::oml::{read_oml, write_oml};

let text = write_oml(&doc.to_raw(), 2).unwrap();
// name: "Ada"
// age: 37
let doc2 = omnist::document::Doc::from_raw(read_oml(&text).unwrap()).unwrap();
assert!(doc.eq_doc(&doc2));
}

Schemas – OSD

OSD (Omnist Schema Definition) is the text language for [Schema]. A quoted token is always a field label; an unquoted identifier is always a schema name (scalar keyword or Ref).

record Person {
    "name": string,
    "age": integer,
}
root Person

omnist::osd::parse_schema parses this text into a Schema; omnist::osd::to_osd writes one back out.

Validation

Schema::validate checks a document’s [Cursor] against the schema’s root type and collects every problem found, not just the first.

#![allow(unused)]
fn main() {
use omnist::osd::parse_schema;

let schema = parse_schema(
    r#"record Person { "name": string, "age": integer } root Person"#,
).unwrap();
let result = schema.validate(&doc.root());
assert!(result.ok());
}

Schema algebra

omnist::ops implements the schema-algebra operations: prune (drop unreachable/unsatisfiable parts), normalize (canonical minimal form), extract (a subschema over a subset of root fields), lint, is_isomorphic, compatible_with/equivalent (subschema relations), and local_signature.

#![allow(unused)]
fn main() {
use omnist::ops::prune;

// `Broken` requires itself and can never be satisfied; `Dead` is
// unreachable from `Root`. `prune` drops both, keeping `Root`.
let pruned = prune(&schema);
assert!(pruned.env().contains_key("Root"));
assert!(!pruned.env().contains_key("Dead"));
assert!(!pruned.env().contains_key("Broken"));
}

Reading & writing other formats

Every format module (omnist::formats::{json,yaml,toml,xml}, plus omnist::oml for OML) exposes a read_*/write_*/check_* triple. write_*/check_* return a [WriteReport] cataloging every adjustment a lossy write had to make (dropped nulls, stringified NaN, …); strict mode turns any adjustment into an error instead of a silent write. See formats/ for what each format actually adjusts, verified against each codec’s own merged PR.

The format registry

Alongside the per-format functions, [Doc::from_format]/to_format/ check_format dispatch by format name through a runtime registry (omnist::formats/register_format/get_format), so a new format can be registered under an arbitrary name at runtime and used everywhere a format name is accepted – not just the five builtins. register_format takes a [Format] (name + read/write closures, plus an optional check); a plugin with no check still works for from_format/to_format, but check_format on it returns a clean error rather than panicking.

#![allow(unused)]
fn main() {
use omnist::document::Doc;
use omnist::{Format, formats, register_format};

let d = Doc::from_format("json", r#"{"a": 1}"#).unwrap();
assert_eq!(d.to_format("yaml").unwrap(), "a: 1");

register_format(Format::new(
    "kv",
    |text| {
        let edges = text
            .split(',')
            .map(|pair| {
                let (k, v) = pair.split_once('=').unwrap();
                (k.to_string(), omnist::document::RawNode::Leaf(
                    omnist::document::Scalar::Str(v.to_string()),
                ))
            })
            .collect();
        Doc::from_raw(omnist::document::RawNode::Edges(edges)).map_err(Into::into)
    },
    |doc| {
        let omnist::document::RawNode::Edges(edges) = doc.to_raw() else {
            return Ok(String::new());
        };
        Ok(edges.iter().map(|(k, v)| {
            let omnist::document::RawNode::Leaf(omnist::document::Scalar::Str(s)) = v else {
                unreachable!()
            };
            format!("{k}={s}")
        }).collect::<Vec<_>>().join(","))
    },
));
assert!(formats().contains(&"kv".to_string()));
assert_eq!(Doc::from_format("kv", "a=1,b=2").unwrap().to_format("kv").unwrap(), "a=1,b=2");
}

Inferring a schema

omnist::infer::infer drafts a record schema that accepts a set of sample Documents, without an allow_any fallback (see limitations.md for why).

#![allow(unused)]
fn main() {
use omnist::infer::infer;

let schema = infer(&samples, "Person").unwrap();
for doc in &samples {
    assert!(schema.accepts(&doc.root()));
}
}

The CLI

The omnist binary (crate omnist-cli) wraps this library as a command-line tool – format, convert, check, validate, infer, and the schema subcommand family. See cli.md for the full command reference.

API reference

Rustdoc API Reference: Generated rustdoc documentation for all workspace crates is available at api/omnist/index.html.

This page enumerates the omnist crate’s public surface: every exported type, function, and method, grouped by area. Signatures are copied from the current source (omnist/src/*.rs); doc comments are condensed, not reproduced verbatim. For why each operation behaves the way it does, see the user guide (worked examples) or the linked omnist-spec sections (the normative behavior).

omnist is published on crates.io, so docs.rs/omnist is the canonical generated API reference; this page and the rustdoc mirror above are curated alternatives, not a substitute for the real thing. All items below are re-exported at the crate root or reachable through their listed module path (omnist::document::Doc, omnist::materialize, etc).

#![allow(unused)]
fn main() {
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
}

VERSION is the crate’s own Cargo package version.

Documents (omnist::document)

The Document model: an ordered, possibly-repeated, possibly-interleaved edge tree. See omnist-spec’s docs/02-document-model.md for the normative model.

#![allow(unused)]
fn main() {
pub const MAX_DEPTH: usize = 200;
pub const MAX_NODES: usize = 1_000_000;
}

The default depth and node-count guards, enforced on every construction path; the limits in force are omnist::limits::Limits (below).

#![allow(unused)]
fn main() {
impl Doc {
    pub fn of_with(value: &Value, limits: &Limits) -> Result<Doc, DocumentError>;
    pub fn from_raw_with(root: RawNode, limits: &Limits) -> Result<Doc, DocumentError>;
}
}

Doc::of and Doc::from_raw under explicit limits; the document remembers them for later add/set.

Safety limits (omnist::limits)

#![allow(unused)]
fn main() {
#[non_exhaustive]
pub struct Limits {
    pub max_depth: u32,
    pub max_nodes: u32,
    pub max_int_digits: u32,
    pub max_input_bytes: u64,
}

impl Limits {
    pub fn with_max_depth(self, n: u32) -> Self;
    pub fn with_max_nodes(self, n: u32) -> Self;
    pub fn with_max_int_digits(self, n: u32) -> Self;
    pub fn with_max_input_bytes(self, n: u64) -> Self;
    pub fn effective_max_depth(&self) -> u32;
    pub fn effective_max_nodes(&self) -> u32;
    pub fn effective_max_int_digits(&self) -> u32;
    pub fn effective_max_input_bytes(&self) -> u64;
    pub fn validate(&self) -> Result<(), DocumentError>;
    pub fn check_input_len(&self, len: u64, hint: &str) -> Result<(), DocumentError>;
}

pub fn input_size_error(max_input_bytes: u64, hint: &str) -> DocumentError;

pub const DEFAULT_MAX_DEPTH: u32 = 200;
pub const MAX_DEPTH_CEILING: u32 = 250;
pub const DEFAULT_MAX_NODES: u32 = 1_000_000;
pub const MAX_NODES_CEILING: u32 = 10_000_000;
pub const DEFAULT_MAX_INT_DIGITS: u32 = 4_300;
pub const MAX_INT_DIGITS_CEILING: u32 = 43_000;
pub const DEFAULT_MAX_INPUT_BYTES: u64 = 64 * 1024 * 1024;
pub const MAX_INPUT_BYTES_CEILING: u64 = 1 << 30;
}

The three universal limits of spec section 2.4 and the maximum input size of D-23 (bytes of the input as received, BOM counted, checked first by every reader: an input of exactly the maximum is accepted, one byte more is refused with document.limit.input-size at $). 0 selects the default; a value above its ceiling is refused by validate, which every *_with entry point calls first. Doc::from_format applies the default maximum to a registered format as well. The explicit-limits readers are oml::read_oml_with, formats::json::read_json_with, formats::toml::read_toml_with, formats::xml::read_xml_with, and formats::yaml::read_yaml_with with YamlReadOptions::with_limits; the plain read_* functions use the defaults. See Limitations.

#![allow(unused)]
fn main() {
pub struct NodeId(/* opaque */);
}

An opaque index into a Doc’s arena.

#![allow(unused)]
fn main() {
pub enum Scalar {
    Null,
    Bool(bool),
    Int(num_bigint::BigInt),
    Float(f64),
    Str(String),
    Date(String),
    Time(String),
    Datetime(String),
}
}

A leaf value. Implements Display (null, true, an integer, a float, a debug-quoted string, or the bare temporal text for Date/Time/ Datetime). Int is arbitrary-precision (num_bigint::BigInt, issue #104) – omnist’s algebra never does integer arithmetic, so there’s no representational ceiling to impose. Date/Time/Datetime (issue #105) each hold already shape-validated, canonical ISO text as a plain String – deliberately no chrono/time dependency, since the algebra never does temporal arithmetic either; nothing outside crate::schema’s is_iso_date/is_iso_time/is_iso_datetime/canonicalize_iso_time/ canonicalize_iso_datetime constructs one, so the invariant holds by construction-site discipline, not the type system.

#![allow(unused)]
fn main() {
pub enum Value {
    Null,
    Bool(bool),
    Int(num_bigint::BigInt),
    Float(f64),
    Str(String),
    Date(String),
    Time(String),
    Datetime(String),
    Array(Vec<Value>),
    Object(IndexMap<String, Value>),
}
}

A plain input value (JSON/YAML/TOML-shaped) – what Doc::of/Doc::add/ Doc::set turn into canonical nodes. Object uses IndexMap so key order survives construction. impl From<Scalar> for Value converts the other way. Int/Date/Time/Datetime carry the same invariants as their Scalar counterparts above.

#![allow(unused)]
fn main() {
pub struct Doc { /* private arena + root */ }

impl Doc {
    pub fn of(value: &Value) -> Result<Doc, DocumentError>;
    pub fn root(&self) -> Cursor<'_>;
    pub fn add(&mut self, at: NodeId, path: &str, label: &str, value: &Value)
        -> Result<NodeId, DocumentError>;
    pub fn set(&mut self, at: NodeId, path: &str, label: &str, value: &Value)
        -> Result<NodeId, DocumentError>;
    pub fn remove(&mut self, at: NodeId, path: &str, label: &str) -> Result<(), DocumentError>;
    pub fn to_grouped(&self) -> Value;
    pub fn to_data(&self) -> Value;
    pub fn eq_doc(&self, other: &Doc) -> bool;
    pub fn from_raw(root: RawNode) -> Result<Doc, DocumentError>;
    pub fn to_raw(&self) -> RawNode;
    pub fn from_format(name: &str, text: &str) -> Result<Doc, crate::error::OmnistError>;
    pub fn to_format(&self, name: &str) -> Result<String, crate::error::OmnistError>;
    pub fn check_format(&self, name: &str)
        -> Result<crate::report::WriteReport, crate::error::OmnistError>;
}
}

A guarded handle on a Document tree.

  • of builds a Doc from a Value.
  • root returns a cursor to the root, path "$".
  • add appends an edge (label, value) under an internal node; a repeated label is how an array grows. Returns the new edge’s NodeId.
  • set replaces all edges under label with a single new edge, positioned at the first old occurrence (set = remove + add).
  • remove removes every edge under label.
  • to_grouped returns a JSON-shaped projection: same-label edges grouped into an array.
  • to_data returns a lossless structural copy (does not re-group repeated labels; used for eq_doc).
  • eq_doc is structural equality: same shape, edge order, labels, leaf values.
  • from_raw/to_raw convert to/from RawNode, preserving edge order and interleaving exactly.
  • from_format/to_format/check_format dispatch by registered format name through omnist::registry (see Registry below).
#![allow(unused)]
fn main() {
pub struct Cursor<'a> {
    pub path: String,
    /* private doc + id */
}

impl<'a> Cursor<'a> {
    pub fn id(&self) -> NodeId;
    pub fn is_leaf(&self) -> bool;
    pub fn value(&self) -> Result<&'a Scalar, DocumentError>;
    pub fn edges(&self) -> Result<Vec<(String, Cursor<'a>)>, DocumentError>;
    pub fn labels(&self) -> Vec<String>;
    pub fn get(&self, label: &str) -> Vec<Cursor<'a>>;
    pub fn get_one(&self, label: &str) -> Result<Cursor<'a>, DocumentError>;
    pub fn count(&self, label: &str) -> usize;
    pub fn child(&self, label: &str) -> Result<Cursor<'a>, DocumentError>;
    pub fn to_raw(&self) -> RawNode;
}
}

A read-only cursor into a Doc’s tree, tracking its own path (built incrementally, e.g. $.a.b[1], for error messages and equality with the Python reference’s Doc.path).

  • value errors if called on an internal node (“not a leaf; use edges()”).
  • edges errors if called on a leaf; returns every child cursor with its label.
  • get/get_one filter children by label; get_one errors unless exactly one match exists.
  • child is an alias for get_one.
  • to_raw returns a lossless RawNode copy of the subtree rooted at this cursor.
#![allow(unused)]
fn main() {
pub enum RawNode {
    Leaf(Scalar),
    Edges(Vec<(String, RawNode)>),
}
}

The raw canonical Document node: a leaf scalar, or an ordered edge list that may repeat and interleave a label arbitrarily. Unlike Value::Object’s IndexMap, RawNode::Edges can represent non-contiguous repeats of the same label exactly, which is why OML’s reader/writer (omnist::oml) walks Doc through this type instead of Value.

Before issue #105, this enum also had a TemporalLeaf(Scalar) variant – a write-hint tag (schema- or OML-grammar-known to be date/time/ datetime-kinded, consumed only by omnist::oml::write_oml) that worked around Scalar having no temporal variant of its own. Issue #105 gave Scalar real Date/Time/Datetime variants, making the tag redundant, and it was removed – OML’s writer now matches on Scalar’s own variant directly.

Schema model (omnist::schema)

The Schema model: closed records of labeled, cardinality-bound fields. See omnist-spec’s docs/03-schema-model.md for the normative model.

#![allow(unused)]
fn main() {
pub enum ScalarKind {
    String,
    Integer,
    Number,
    Boolean,
    Date,
    Time,
    Datetime,
}

impl ScalarKind {
    pub const ALL: [ScalarKind; 7];
    pub fn as_str(&self) -> &'static str;
    pub fn parse(name: &str) -> Result<ScalarKind, SchemaError>;
}
}

One of the seven predefined value kinds a Scalar can hold. parse reads a kind name as it appears in schema text (e.g. "date").

#![allow(unused)]
fn main() {
pub struct Scalar { /* private kind + nullable */ }

impl Scalar {
    pub const fn new(kind: ScalarKind, nullable: bool) -> Self;
    pub fn named(name: &str, nullable: bool) -> Result<Self, SchemaError>;
    pub fn kind(&self) -> ScalarKind;
    pub fn is_nullable(&self) -> bool;
}

pub const STRING: Scalar;
pub const INTEGER: Scalar;
pub const NUMBER: Scalar;
pub const BOOLEAN: Scalar;
pub const DATE: Scalar;
pub const TIME: Scalar;
pub const DATETIME: Scalar;

pub fn nullable(scalar: Scalar) -> Scalar;
}

One of the seven predefined value types, optionally nullable. The seven pub consts are the non-nullable form of each kind; nullable(scalar) returns a copy that also accepts null (the ? form in OSD text). Implements Display (e.g. integer, string?).

#![allow(unused)]
fn main() {
pub struct Ref {
    pub name: String,
}

impl Ref {
    pub fn new(name: impl Into<String>) -> Self;
}
}

A reference to a named record in a Schema’s environment. Display renders as ref(name).

#![allow(unused)]
fn main() {
pub enum FieldType {
    Scalar(Scalar),
    Ref(Ref),
    Any,
}
}

A field’s type. Any accepts every legal Document value (ported from Python’s AnyType/ANY singleton, shipped since Python v0.5.0) – it is neither a Scalar (no kind, no nullable flag) nor a Ref (names nothing). impl From<Scalar> and impl From<Ref> construct a FieldType from either.

#![allow(unused)]
fn main() {
pub struct Field {
    pub label: String,
    pub ty: FieldType,
    pub min: usize,
    pub max: Option<usize>,
}

impl Field {
    pub fn new(label: impl Into<String>, ty: impl Into<FieldType>, min: usize, max: Option<usize>)
        -> Result<Self, SchemaError>;
    pub fn required(label: impl Into<String>, ty: impl Into<FieldType>) -> Result<Self, SchemaError>;
    pub fn cardinality_str(&self) -> String;
}
}

One named, cardinality-bound field slot of a record: label of ty, occurring [min, max] times (max = None is unbounded). new errors if max < min. required is the common [1,1] case. cardinality_str renders a human-readable cardinality description (“exactly 1”, “0 or 1”, “at least N”, “between N and M”).

#![allow(unused)]
fn main() {
pub struct Record { /* private fields + label index */ }

impl Record {
    pub fn new(fields: Vec<Field>) -> Result<Self, SchemaError>;
    pub fn fields(&self) -> &[Field];
    pub fn field(&self, label: &str) -> Option<&Field>;
}
}

A closed set of named fields. new rejects a duplicate field label. fields preserves declaration order; equality (PartialEq) is declaration-order-independent (a record is an unordered field set at the model layer, per omnist-spec §3.1).

#![allow(unused)]
fn main() {
pub enum ErrorCode {
    UnexpectedField,
    Cardinality,
    TypeMismatch,
    NullNotAllowed,
    ShapeMismatch,
}

impl ErrorCode {
    pub fn as_str(&self) -> &'static str;
}
}

A stable, machine-readable validation failure code.

#![allow(unused)]
fn main() {
pub struct ValidationError {
    pub path: String,
    pub message: String,
    pub code: ErrorCode,
}

pub struct ValidationResult { /* private errors */ }

impl ValidationResult {
    pub fn new() -> Self;
    pub fn ok(&self) -> bool;
    pub fn errors(&self) -> &[ValidationError];
}
}

ValidationResult is the outcome of Schema::validate: empty on success, one entry per problem found (validation collects every error, not just the first). Display renders "valid" or an indented "invalid:" listing.

#![allow(unused)]
fn main() {
pub fn matches_kind(value: &document::Scalar, kind: ScalarKind) -> bool;
}

Does value match scalar kind kind? Validation only checks, never converts (see materialize below for the upgrading counterpart). Date/ Time/Datetime match either the real document::Scalar variant or a plain Str whose text independently shape-validates as that kind’s ISO form (verified against Python’s own hybrid matches_kind, issue #105) – so a JSON- or XML-sourced document with a date-shaped string field genuinely satisfies kind: date at validate time with no materialize upgrade required first. Integer/Number stay strict (no string-shape fallback), matching Python there too.

#![allow(unused)]
fn main() {
pub enum Resolved<'a> {
    Record(&'a Record),
    Scalar(Scalar),
    Any,
}

pub struct Schema { /* private root + env */ }

impl Schema {
    pub fn new(root: Ref, env: IndexMap<String, Record>) -> Result<Self, SchemaError>;
    pub fn root(&self) -> &Ref;
    pub fn env(&self) -> &IndexMap<String, Record>;
    pub fn resolve(&self, ty: &FieldType) -> Resolved<'_>;
    pub fn validate(&self, cursor: &document::Cursor<'_>) -> ValidationResult;
    pub fn accepts(&self, cursor: &document::Cursor<'_>) -> bool;
}
}

A schema: a root reference plus an environment of named records.

  • new checks every Ref (the root’s, and every field’s) resolves within env, and enforces that no record is named after a scalar keyword or any (a bare name in type position always resolves to the builtin first, so such a record could never be referenced).
  • resolve maps a FieldType to a Resolved value: a bare Scalar resolves to itself, Any to Resolved::Any, a Ref to a single environment lookup (guaranteed to succeed once a Schema exists).
  • validate walks cursor against the schema’s root type, collecting every problem found.
  • accepts is validate(cursor).ok().

Schema text – OSD (omnist::osd)

OSD (Omnist Schema Definition) is the text language for Schema. See omnist-spec’s docs/03-schema-model.md for the grammar.

#![allow(unused)]
fn main() {
pub fn parse_schema(text: &str) -> Result<Schema, SchemaError>;
pub fn to_osd(schema: &Schema, indent: Option<usize>) -> Result<String, WriteError>;
}

parse_schema parses OSD text into a Schema. to_osd serializes a Schema back to OSD text: indent: None renders a single-line, machine-oriented form; Some(n) sets the pretty-printed indent width in spaces. Both forms round-trip through parse_schema. Labels are escaped per OSD-15 (a backslash is written \\, a double quote \", nothing else); to_osd fails with write.unsupported-value (OSD-14) for a field label containing a C0 control character, which OSD text cannot spell, and (OSD-16, S-24) for a field with max = 0 ([0,0]: representable in the model, rejected by every text reader) – both at the record path. Run omnist::ops::prune first: it removes max = 0 fields from every record it rebuilds (an unsatisfiable root is kept intact, spec 6.5).

Schema::new enforces S-8: a record name or Ref target name (the root’s and every field’s) that is not [A-Za-z_][A-Za-z0-9_]* fails with schema.invalid-name at the path $, the offending name in the message only.

Formats (omnist::formats and omnist::oml)

Codecs over the canonical Document model. Every builtin format follows the same read_*/write_*/check_* naming convention. Unlike OML (always lossless), JSON/YAML/TOML/XML writers are lenient by default – they adjust values that don’t fit the target format and record the change in a WriteReport (see Reporting); strict: true makes the writer return WriteError instead. See omnist-spec’s docs/04-formats.md for the per-format lossiness rules.

Note: OML lives in its own top-level omnist::oml module, not under omnist::formats – it is omnist’s own native format, not one of the four lossy interchange codecs.

#![allow(unused)]
fn main() {
// omnist::formats::json
pub fn read_json(text: &str) -> Result<Doc, OmnistError>;
pub fn write_json(doc: &Doc, indent: Option<usize>, strict: bool, report: Option<&mut WriteReport>)
    -> Result<String, WriteError>;
pub fn check_json(doc: &Doc) -> WriteReport;
}

JSON: write_json’s indent: None writes compact JSON; Some(n) pretty-prints with n spaces per level. NaN/Infinity/-Infinity are adjusted to null in lenient mode.

#![allow(unused)]
fn main() {
// omnist::formats::yaml
pub fn read_yaml(text: &str) -> Result<Doc, OmnistError>;
pub fn write_yaml(doc: &Doc, strict: bool, report: Option<&mut WriteReport>)
    -> Result<String, WriteError>;
pub fn check_yaml(doc: &Doc) -> WriteReport;
pub fn read_yaml_with(text: &str, options: &YamlReadOptions) -> Result<Doc, OmnistError>;
pub struct YamlReadOptions { pub max_alias_expansion: u32, pub max_expanded_slots: u32 }
pub fn with_max_alias_expansion(mut self, n: u32) -> Self; // on YamlReadOptions
pub fn with_max_expanded_slots(mut self, n: u32) -> Self; // on YamlReadOptions
pub const DEFAULT_MAX_ALIAS_EXPANSION: u32 = 50;
pub const MAX_ALIAS_EXPANSION_CEILING: u32 = 10_000;
pub const DEFAULT_MAX_EXPANDED_SLOTS: u32 = 1_000_000;
pub const MAX_EXPANDED_SLOTS_CEILING: u32 = 10_000_000;
}

YAML: block style, 2-space indent, insertion order preserved. read_yaml accepts exactly one YAML document (a multi-document stream errors, matching Python’s yaml.safe_load); empty/blank input parses as a Null document. Both readers refuse input whose alias expansion factor exceeds the maximum (default 50) or that holds a self-referential anchor, with document.limit.alias-expansion at $ and before anything is expanded (spec D-18/D-19/D-20), and an input with an alias or merge key whose root materializes more than the maximum expanded size (default 1,000,000 slots) with document.limit.expanded-size (D-22); a malformed merge is parse.codec-syntax. read_yaml_with takes a YamlReadOptions whose max_alias_expansion and max_expanded_slots (0 selects the default; above 10000 and 10,000,000 respectively is refused) change them. See YAML.

#![allow(unused)]
fn main() {
// omnist::formats::toml
pub fn read_toml(text: &str) -> Result<Doc, OmnistError>;
pub fn write_toml(doc: &Doc, strict: bool, report: Option<&mut WriteReport>)
    -> Result<String, WriteError>;
pub fn check_toml(doc: &Doc) -> WriteReport;
}

TOML: write_toml errors if the root isn’t an object (TOML documents are always tables at the top level). null values are stripped, recorded in the report.

#![allow(unused)]
fn main() {
// omnist::formats::xml
pub fn read_xml(text: &str) -> Result<Doc, OmnistError>;
pub fn read_xml_with_schema(text: &str, schema: &Schema) -> Result<Doc, OmnistError>;
pub fn write_xml(doc: &Doc, strict: bool, report: Option<&mut WriteReport>)
    -> Result<String, WriteError>;
pub fn check_xml(doc: &Doc) -> WriteReport;
}

XML: write_xml requires a single-rooted Document (exactly one top-level edge) and errors otherwise; check_xml does not enforce that shape (it mirrors Python’s check_xml, a plain adjustment scan with no root-shape guard).

#![allow(unused)]
fn main() {
// omnist::oml
pub fn read_oml(text: &str) -> Result<RawNode, ParseError>;
pub fn write_oml(node: &RawNode, indent: usize) -> Result<String, WriteError>;
pub fn write_oml_compact(node: &RawNode) -> Result<String, WriteError>;
pub fn check_oml(doc: &Doc) -> WriteReport;
}

OML (Omnist Markup Language), omnist’s own native format: every Document round-trips through it exactly, with no adjustment ever needed. read_oml supports the full OML-Core grammar plus OML-Extended raw-string ('...') and triple-quoted ("""...""") string spellings on read only – write_oml/write_oml_compact only ever emit OML-Core double-quoted strings. write_oml_compact is the single-line form (edges joined by "; ", no newlines); both round-trip through read_oml. check_oml always returns an empty WriteReport (OML is lossless) – it exists only so the "oml" registry entry has a check callable like the other four formats.

Materialize (omnist::materialize)

#![allow(unused)]
fn main() {
pub fn materialize(node: &RawNode, schema: Option<&Schema>) -> Result<RawNode, MaterializeError>;
}

Schema-directed deserialization: a copy of node with leaf values upgraded to match schema (e.g. 1.0 -> 1 for an integer field, 1 -> 1.0 for a number field – upgrades are always value-exact), guaranteed to conform to it – or every reason it can’t, collected into one MaterializeError (never just the first problem found). schema: None is a no-op passthrough – node is cloned back unchanged, with no validation performed. An Any-typed field passes its node through completely untouched. See omnist-spec’s docs/03-schema-model.md for the scalar-upgrade rules.

Infer (omnist::infer)

#![allow(unused)]
fn main() {
pub struct AnyFallback {
    pub location: String,
    pub reason: String,
}

pub fn infer(samples: &[Doc], root_name: &str) -> Result<Schema, SchemaError>;
pub fn infer_with_report(samples: &[Doc], root_name: &str, allow_any: bool)
    -> Result<(Schema, Vec<AnyFallback>), SchemaError>;
}

infer drafts a record Schema (rooted at root_name) that accepts every sample in samples; every sample’s root must be an object, and an empty samples list errors. It always infers with allow_any: false – a label whose samples disagree in a way that can’t resolve to one precise type is a SchemaError.

infer_with_report additionally accepts allow_any: true: instead of erroring on an ambiguous field, it opens the field as FieldType::Any and records one AnyFallback (location reads RecordName.label; reason says why). With allow_any: false it behaves exactly like infer, and the returned Vec<AnyFallback> is always empty.

Neither function auto-normalizes – the raw result may contain structurally-identical duplicate records; call omnist::ops::normalize (see Operations / algebra below) where a canonical minimal schema is wanted.

Operations / algebra (omnist::ops)

The schema compatibility and structural-transform algebra. See omnist-spec’s docs/03-schema-model.md for the algebra’s normative rules and definitions.

#![allow(unused)]
fn main() {
// omnist::ops::subschema
pub fn compatible_with(a: &Schema, b: &Schema) -> bool;
pub fn equivalent(a: &Schema, b: &Schema) -> bool;
}

compatible_with(a, b) is true iff every document a accepts is also accepted by b (a is a subschema of b / b is backward-compatible with a). equivalent(a, b) is compatible_with(a, b) && compatible_with(b, a).

#![allow(unused)]
fn main() {
// omnist::ops::extract
pub fn extract(s: &Schema, keep: &[&str]) -> Result<Schema, SchemaError>;
}

The minimal subschema of s that only recognizes documents built from labels in keep. Errors if deleting the other labels would invalidate the root record (deleting a mandatory field is a hard error, never silently loosened to optional).

#![allow(unused)]
fn main() {
// omnist::ops::prune
pub fn satisfiable_set(s: &Schema) -> IndexSet<String>;
pub fn is_empty(s: &Schema) -> bool;
pub fn prune(s: &Schema) -> Schema;
}

satisfiable_set returns the env record names that admit at least one finite document (a least fixpoint: a record is satisfiable iff every mandatory field is a Scalar/Any or a Ref to a satisfiable record). is_empty is true iff the root record itself is unsatisfiable (the schema’s language is empty). prune returns an equivalent schema with everything that can never match removed: unreachable records, max == 0 fields, optional fields typed to an unsatisfiable record, and records left unreachable/unsatisfiable afterward. If the root itself is unsatisfiable, its fields are left untouched (stripping them would produce a different, satisfiable schema) and only the rest of the environment is reduced.

#![allow(unused)]
fn main() {
// omnist::ops::minimize
pub fn equivalence_classes(s: &Schema) -> Vec<Vec<String>>;
pub fn normalize(s: &Schema) -> Schema;
}

equivalence_classes partitions s.env’s record names into structural-equivalence classes via partition refinement (an initial target-blind structural-key grouping, refined to a fixpoint by which block each same-labeled ref field points to); it does not prune first. normalize returns the canonical minimal schema equivalent to s: prunes first, then collapses each equivalence class to its lexicographically smallest member name. An unsatisfiable pruned root is returned unchanged (partition refinement over an empty-language core isn’t meaningful).

#![allow(unused)]
fn main() {
// omnist::ops::lint
pub struct LintFinding {
    pub code: &'static str,
    pub severity: &'static str,
    pub location: String,
    pub message: String,
}

pub fn lint(s: &Schema) -> Vec<LintFinding>;
}

Non-destructive structural diagnostics for a schema – reports, never mutates. Four checks: unsatisfiable-record (warning; a reachable record no finite document can match), unreachable-record (warning; defined but not reachable from root), duplicate-record (warning; two or more structurally identical records under different names), and any-field (info; an inventory of every any-typed field). Findings are sorted deterministically by (code, location).

#![allow(unused)]
fn main() {
// omnist::ops::isomorphic
pub fn is_isomorphic(a: &Schema, b: &Schema) -> bool;
}

True iff normalized schemas a and b are isomorphic: there is a bijection between their env record names under which the two root records (and everything reachable from them) match exactly. Both inputs are assumed already normalized by the caller. Empty-schema convention: if both a and b are unsatisfiable, they’re treated as isomorphic (both accept the empty language); if exactly one is, they’re not. This is not a committed public surface the way equivalent is – it exists as an independent cross-check oracle used in this crate’s own test suite, mirroring the Python reference’s choice to keep the equivalent function private there.

#![allow(unused)]
fn main() {
// omnist::ops::signature
pub enum ShapeKey {
    Scalar(ScalarKind, bool),
    Ref,
    Any,
}
pub type FieldKey = (String, usize, Option<usize>, ShapeKey);
pub type LocalSignature = Vec<FieldKey>;

pub fn local_signature(rec: &Record) -> LocalSignature;
}

local_signature is a record’s target-blind structural key: every field’s (label, min, max, shape), sorted by label, used as the initial partition for minimize’s refinement and as the comparison key for isomorphic’s signature matching.

Registry (omnist::registry)

Name-keyed runtime dispatch: read/write/check a Doc by format name, and register your own format plugins.

#![allow(unused)]
fn main() {
pub type ReadFn = dyn Fn(&str) -> Result<Doc, OmnistError> + Send + Sync;
pub type WriteFn = dyn Fn(&Doc) -> Result<String, OmnistError> + Send + Sync;
pub type CheckFn = dyn Fn(&Doc) -> WriteReport + Send + Sync;

pub struct Format {
    pub name: String,
    pub read: Arc<ReadFn>,
    pub write: Arc<WriteFn>,
    pub check: Option<Arc<CheckFn>>,
}

impl Format {
    pub fn new(
        name: impl Into<String>,
        read: impl Fn(&str) -> Result<Doc, OmnistError> + Send + Sync + 'static,
        write: impl Fn(&Doc) -> Result<String, OmnistError> + Send + Sync + 'static,
    ) -> Self;
    pub fn with_check(self, check: impl Fn(&Doc) -> WriteReport + Send + Sync + 'static) -> Self;
}

pub fn register_format(fmt: Format);
pub fn get_format(name: &str) -> Result<Format, OmnistError>;
pub fn formats() -> Vec<String>;
}

Format bundles a name with read/write callables and an optional check – a plugin built with Format::new alone has no check, and Doc::check_format returns a clean error (not a panic) if invoked on it. register_format adds or replaces a format under fmt.name, usable everywhere a format name is accepted (Doc::from_format/to_format/ check_format). get_format looks up a registered Format by name, or an OmnistError::Format naming every currently-registered name, sorted. formats lists all registered format names, sorted. The five builtins (json, yaml, toml, xml, oml) are always registered.

Errors (omnist::error)

#![allow(unused)]
fn main() {
pub struct DocumentError {
    pub path: String,
    /// The spec taxonomy code (`document.*`, `format.*`), when one applies.
    pub code: Option<String>,
    pub message: String,
}

pub struct SchemaError {
    pub path: String,
    pub code: String,
    pub message: String,
}

pub struct ParseError {
    pub line: usize,
    pub col: usize,
    /// The spec's `parse.*` code, e.g. `parse.unexpected-token`,
    /// `parse.codec-syntax`.
    pub code: String,
    pub message: String,
}

pub struct FormatError(pub String);

pub struct WriteError {
    pub message: String,
    pub report: Option<crate::report::WriteReport>,
    /// The Document path and spec code (`write.unsupported-value`,
    /// `format.multiple-roots`) of a failure the taxonomy names.
    pub path: Option<String>,
    pub code: Option<String>,
}

impl WriteError {
    pub fn new(message: impl Into<String>) -> Self;
    pub fn with_diagnostic(
        path: impl Into<String>,
        code: impl Into<String>,
        message: impl Into<String>,
    ) -> Self;
    pub fn with_report(message: impl Into<String>, report: crate::report::WriteReport) -> Self;
    pub fn report(&self) -> Option<&crate::report::WriteReport>;
}

pub struct MaterializeError(pub crate::schema::ValidationResult);

impl MaterializeError {
    pub fn new(result: crate::schema::ValidationResult) -> Self;
    pub fn result(&self) -> &crate::schema::ValidationResult;
    pub fn errors(&self) -> &[crate::schema::ValidationError];
}

pub enum OmnistError {
    Document(DocumentError),
    Schema(SchemaError),
    Materialize(MaterializeError),
    Parse(ParseError),
    Write(WriteError),
    Format(FormatError),
}
}

OmnistError is the crate-wide top-level error; each leaf type is a #[from] variant, mirroring the Python reference’s OmnistError/SchemaError/ParseError/WriteError/DocumentError hierarchy.

  • DocumentError – a Document operation is invalid, or a plain value is not a legal Document (construction/mutation outside the Document model, or an operation that doesn’t fit the node it’s called on).
  • SchemaError – a Schema definition is invalid (bad cardinality, duplicate field label, unknown scalar/ref name).
  • ParseError – source text could not be parsed (OML, or any of the four codecs); carries a “line N, col N: msg” position, the spec’s parse.* code, and position() (the line:col text-position path).
  • FormatError – an unknown format name was looked up in the format registry.
  • WriteError – an in-memory Document could not be written; carries an optional WriteReport when a strict-mode format writer raised because of accumulated adjustments.
  • MaterializeError – a freshly-read node could not be made to conform to a Schema; wraps the same ValidationResult Schema::validate uses.

Reporting (omnist::report)

#![allow(unused)]
fn main() {
pub enum Severity {
    Warning,
    Error,
}

pub struct Adjustment {
    pub path: String,
    pub code: String,
    pub message: String,
    pub severity: Severity,
}

pub struct WriteReport { /* private adjustments */ }

impl WriteReport {
    pub fn new() -> Self;
    pub fn add(&mut self, path: impl Into<String>, code: impl Into<String>,
        message: impl Into<String>, severity: Severity);
    pub fn adjustments(&self) -> &[Adjustment];
    pub fn warnings(&self) -> Vec<&Adjustment>;
    pub fn errors(&self) -> Vec<&Adjustment>;
    pub fn is_ok(&self) -> bool;
    pub fn is_empty(&self) -> bool;
    pub fn len(&self) -> usize;
    pub fn iter(&self) -> std::slice::Iter<'_, Adjustment>;
}

pub fn finish_write(text: String, rep: WriteReport, strict: bool, report: Option<&mut WriteReport>)
    -> Result<String, WriteError>;
}

Adjustment reports for lossy writes. Writing a Doc to a format that can’t hold every value losslessly means the writer has to adjust the data; each adjustment is recorded as an Adjustment in a WriteReport rather than lost silently.

  • Severity::Warning is conventional/recoverable (e.g. a date written as a string); Severity::Error is likely to surprise or corrupt (e.g. NaN written as JSON null).
  • WriteReport::is_ok is true iff there are no error-severity entries – warnings alone don’t flip it (this is the report’s bool conversion in the Python reference).
  • finish_write is the standard strict/report handling every format writer applies to its own accumulated WriteReport: if report is given, rep’s adjustments are copied into it; if strict and rep has any adjustments, returns WriteError carrying rep; otherwise returns text.

Rustdoc (cargo doc)

CLI reference

The omnist binary (omnist-cli, issue #24) wraps the library crate as a command-line tool. This page mirrors the actual clap command surface in omnist-cli/src/lib.rs; every example below is drawn from a real assertion in omnist-cli/tests/cli.rs.

omnist <COMMAND>

Commands:
  format    Canonicalize an OML document (the only format with no other tool for this)
  convert   Convert a document between formats (one in, one out)
  check     Report what writing as --to would adjust, without ever writing
  validate  Check a document against a schema (no schema-directed upgrading)
  infer     Draft a schema from example documents (all the same format)
  schema    Operate on a Schema (OSD)

Every subcommand accepts - for its input file meaning stdin, --output (-o)/stdout for where the result goes, and --json to wrap CLI-level errors as structured JSON on stderr instead of plain text. Exit codes: 0 success, 1 a conformance/adjustment failure the command itself reports, 2 a usage or parse error.

Every subcommand that reads a Document (format, convert, check, validate, infer) accepts --max-input-bytes N (1 to 1 GiB, default 64 MiB): an input of more than N bytes is refused with document.limit.input-size at $ (spec D-23), exit 2. The count is bytes of the input as received, a leading BOM included, taken before decoding; the CLI stops reading at N + 1 bytes. The message says how to raise it:

error: input exceeds the maximum input size (40 bytes); use --max-input-bytes N to raise it

Schema files (--schema, the schema subcommands) are not Documents and are not bounded by it.

format

Canonicalizes an OML document – the only format with no other canonicalization tool.

$ omnist format doc.oml
a: 1
b: "x"

--compact prints single-line OML (; -separated) instead of one edge per line.

convert

Converts one format to another in a single pass (--from/--to are required; json, yaml, toml, xml, oml).

$ omnist convert doc.json --from json --to oml
a: 1
b: "x"

--schema <file> materializes the document against an OSD schema before writing (schema-directed deserialization, e.g. numeric-string coercion); --strict turns any lossy-write adjustment into an error instead of a silent write; --report prints the adjustment report to stderr; --result-format {text,json,oml} controls how a report (or --schema outcome) is rendered. Each adjustment carries the spec’s section 8.3.8 code (format.value-stringified, format.string-line-break-char, format.temporal-stringified, format.interleaving-lost, …). Through 0.7.0-alpha format.value-stringified was spelled value.stringified and format.string-line-break-char was spelled string.line-break-char; a script matching the old output must change. Through 0.8.0-alpha an XML null was written as an empty element with a null.omitted warning; from 0.9.0-alpha convert --to xml fails on a null leaf (exit 2, spec C-10), --strict or not, and check --to xml reports it as an error entry with code write.unsupported-value (see XML).

check

Like convert, but never writes – reports what a --to write would adjust.

$ omnist check doc.json --from json --to toml
no adjustments

validate

Checks a document against an OSD schema (--schema <file>, required). No schema-directed upgrading happens here – validate only checks, convert --schema is what materializes.

$ omnist validate doc.json --from json --schema person.osd
valid

An invalid document exits 1 and prints every collected ValidationError; --json emits {"ok": false, "errors": [{"path", "message", "code"}, ...]} with the same stable, family-namespaced code strings schema::ErrorCode::as_str returns per omnist-spec §8.3.1 ("validate.type-mismatch", "validate.cardinality", …; convert --schema’s materialize path uses the same validate.* codes for shape and cardinality problems, and "materialize.inexact-conversion" for a leaf that cannot be converted exactly – omnist-spec §8.3.5).

infer

Drafts a schema from one or more sample documents (--from, required; all samples must be the same format).

$ omnist infer doc.json --from json
record Root {
    "a": integer,
    "b": string,
}
root Root

--allow-any is accepted by the argument parser (matching Python’s surface) but returns a clear “not supported yet” error – this port’s [omnist::infer::infer] has no any-fallback (see limitations.md).

schema <subcommand>

Operates on an OSD schema file (all subcommands take a .osd file, - for stdin):

  • format – canonicalize OSD text.
  • normalize – canonical minimal form (structurally identical records collapsed to one).
  • prune – drop unreachable/unsatisfiable records and fields.
  • is-empty – exit 0 if the schema’s language is empty, 1 otherwise (and prints true/false).
  • extract --keep <labels> – a subschema over a subset of root fields.
  • lint [--severity {info,warning}] – structural lint findings.
  • compatible-with <a> <b> / equivalent <a> <b> – subschema relations between two schema files.
$ omnist schema prune schema.osd   # Dead (unreachable) record is gone
record Root {
    "a": string,
}
root Root

Known scope gaps (library limitations, not CLI bugs)

  • --arrays: accepted wherever the Python CLI accepts it (OML output paths), but this port’s omnist::oml::write_oml has no arrays mode yet – passing it where it would apply returns a clear “not supported yet” error (exit 2) rather than being silently ignored or faked. Where --arrays has no effect per spec, it is accepted and silently ignored, matching Python.
  • schema-directed --schema on convert: implemented via omnist::materialize::materialize on the raw node after a schema-less read, mirroring Python’s own _materialize(node, schema) call inside each reader.

Formats

omnist reads and writes five formats through one canonical Doc model: JSON, YAML, TOML, XML, and omnist’s own native OML. Each format page below documents that format’s specific round-trip behavior and any lossy corners it has.

JSON

omnist::formats::json::{read_json, write_json, check_json}. Ported from ~/dev/omnist/omnist/formats.py’s read_json/write_json/check_json; see omnist/src/formats/json.rs’s module doc for the full detail behind every claim below.

#![allow(unused)]
fn main() {
use indexmap::IndexMap;
use omnist::document::{Doc, Value};
use omnist::formats::json::{read_json, write_json};

let mut fields = IndexMap::new();
fields.insert("name".to_string(), Value::Str("Ada".to_string()));
fields.insert("age".to_string(), Value::Int(37));
let doc = Doc::of(&Value::Object(fields)).unwrap();

let text = write_json(&doc, Some(2), true, None).unwrap();
let doc2 = read_json(&text).unwrap();
assert!(doc.eq_doc(&doc2));
}

The one lossy JSON adjustment: NaN/Infinity

JSON’s grammar has no token for NaN/Infinity/-Infinity. Writing one of these floats substitutes null and records a temporal.stringified-style adjustment via WriteReport; strict mode turns this into an error instead of a silent substitution.

NaN and Infinity are not JSON on read (C-19)

The bare tokens NaN, Infinity and -Infinity are parse.codec-syntax: NaN and Infinity are numbers only where the format spells them (YAML .nan .inf; TOML and OML nan inf). Through 0.11.1-alpha the reader accepted them as floats, a document it could not write back as JSON.

No native temporal type

JSON has no native date/time/datetime literal, so a genuine Scalar::Date/Time/Datetime (issue #105) writes the same way a Scalar::Str does – its raw ISO text, quoted – and check_json records a format.temporal-stringified warning, matching Python (which stringifies a live datetime.date/time object and records its own temporal.stringified-equivalent warning). This is a real adjustment now, not merely unreachable: before issue #105, Scalar had no temporal variant at all, so this codec’s decoder path (in the conformance harness) collapsed such values to a plain string before the writer ever ran, making the adjustment structurally unreachable in this port (formats-json/basic/temporal-leaf-is-stringified-on-write, previously skipped, now passes for real).

The text is the value’s ISO-8601 spelling with its seconds always written (12:00 is written 12:00:00) and the offset kept (E-37).

Integer digit cap (arbitrary-precision, matching Python – issue #104)

A JSON integer literal over 4300 digits is a DocumentError (document.limit.int-digits, at the Document path of the integer, for example $.a; mirrors CPython’s sys.set_int_max_str_digits guard, which fires inside json.loads before Python ever sees the value). The cap is Limits::max_int_digits and read_json_with changes it; see Limitations. Under that cap, this port’s Scalar::Int/Value::Int hold the value exactly, at any size – num_bigint::BigInt, not a fixed-width integer – matching Python’s own arbitrary-precision int with no additional ceiling. (Previously i64-backed, ~19 significant digits; that was a real spec-conformance bug, not a disclosed representational gap – see Limitations.)

YAML

omnist::formats::yaml::{read_yaml, write_yaml, check_yaml}. Ported from ~/dev/omnist/omnist/formats.py’s read_yaml/write_yaml/check_yaml; see omnist/src/formats/yaml.rs’s module doc for the full detail.

#![allow(unused)]
fn main() {
use indexmap::IndexMap;
use omnist::document::{Doc, Value};
use omnist::formats::yaml::{read_yaml, write_yaml};

let mut fields = IndexMap::new();
fields.insert("name".to_string(), Value::Str("Ada".to_string()));
fields.insert("age".to_string(), Value::Int(37));
let doc = Doc::of(&Value::Object(fields)).unwrap();

let text = write_yaml(&doc, true, None).unwrap();
let doc2 = read_yaml(&text).unwrap();
assert!(doc.eq_doc(&doc2));
}

Scalar-tag resolution: PyYAML’s rules, not yaml_rust2’s

yaml_rust2’s own resolver only recognizes true/false for booleans (YAML 1.2 core schema). This module ignores that and re-implements PyYAML’s YAML-1.1 implicit-resolver regexes instead, live-checked against PyYAML (this project’s Python reference): yes/no/on/off (and case variants) are also booleans; bare y/n are not and stay strings. Quoted scalars are never auto-typed, matching PyYAML (implicit resolution only applies to plain-style scalars).

Merge keys (<<)

yaml_rust2 has no built-in merge-key support; this module implements the YAML merge-key spec directly (a << key’s value must be a mapping or sequence of mappings; anything else is parse.codec-syntax, see the alias expansion limit).

An explicit tag decides what a << is (D-27): a plain << and a << tagged !!merge are merge keys, with the same shape rule and the same alias accounting. A quoted << and a !!str << are the string <<, an ordinary key that merges nothing ({!!str <<: *p} is an edge << holding a copy of p). A !!merge on anything but << is not a supported tag.

The order is normative (omnist-spec formats/yaml.md): merged entries come first, in source order (<<: [*a, *b] is a’s entries, then b’s – never reversed), then the mapping’s own. A key supplied more than once resolves to one edge at the position of its first occurrence, carrying the mapping’s own value when it writes one, otherwise the earliest alias’s. A merged mapping’s own << is flattened first, so a grandparent’s entries arrive before the parent’s.

Duplicate keys are rejected (C-12)

A mapping that writes the same key twice, in block or flow style and at any depth, fails the read with parse.codec-syntax: a: 1 / b: 2 / a: 3 is an error, not the edges a = 3, b = 2. Keys compare as strings, so a and "a" are one key; the same key in two different mappings is fine; a key a merge key supplies is not a repeat of the same key written in the mapping, which overrides it. This is the opposite of JSON, where the last value wins (C-11). The position reported is 1:1: the reader finds the repeat after the event stream has been built into a tree, which keeps no positions, and E-31 lets an implementation report that when it has no better one.

!!omap, !!pairs and !!set are refused (C-13)

A node tagged !!omap, !!pairs or !!set, whatever its kind or content, is refused with the code format.tag-forbidden at path $. It is well-formed YAML outside the supported profile, so it is a DocumentError and not a syntax error. Write the plain sequence of single-key mappings, or the plain mapping, instead; every other tag keeps the reading it had.

Byte-order marks

A leading U+FEFF is stripped once, before the YAML library sees the text (D-15); a second is rejected at 1:1 as parse.codec-syntax (D-21), because yaml_rust2 would otherwise keep it as part of the first key. The one shape this costs is an unquoted first key beginning with the mark; quote it. The writer quotes any string that starts with the mark, so written YAML always reads back.

Alias expansion limit (D-18, D-19, D-20)

The reader enforces omnist-spec section 2.4.1: for every candidate node a it computes E(a) = W(a) / S(a), where S is the number of value slots written in a’s own definition (an alias counts as one slot) and W the number of slots materialized when a is expanded (an alias contributes its target’s whole W). It rejects the input with document.limit.alias-expansion at path $ when any E exceeds the maximum, which is 50 by default (omnist::formats::yaml::DEFAULT_MAX_ALIAS_EXPANSION).

  • Candidates are every anchored node, and every mapping and sequence whether anchored or not: the document root, an unanchored container that merely holds aliases, and a mapping written inline as a merge source (<<: {a: 1}). Scalars are never checked. Each candidate is measured over its own subtree, so one amplifying mapping is rejected where it sits, not diluted by a large document around it.
  • A plain alias (*b) is one slot in S and contributes W(b) to W. A merge-key reference (<<: *b) is one slot in S and contributes W(b) - 1 (the container is flattened into the referring mapping, not reproduced). <<: [*p, *q] is one slot in S, no slot of its own in W, and each member contributes W - 1.
  • A sequence in merge-value position is a carrier, anchored or not (D-18a). <<: &s [*p, *q] is the unanchored carrier with a name attached: no slot in W or S, and not a candidate. <<: *s, an alias to a sequence, contributes the sum over its members of W - 1 (not W(s) - 1) and one slot, the << entry, in S. An ordinary value s: &s [*p, *q] stays an ordinary candidate with W = 1 + sum W(member), and a plain alias to a carrier (t: *s) materializes that list. A mapping written inline in a carrier adds its written slots, less its container, to the referrer’s S; one anchored there is a candidate where it is defined.
  • Malformed merges are syntax errors (D-18a). A merge value must be a mapping or a sequence of mappings. A scalar value, a scalar member of a carrier, a sequence inside a carrier (<<: [[{a: 1}]] or <<: [*s] where s is a sequence) and an alias to a scalar or to a sequence that holds anything but mappings are parse.codec-syntax at the offending node’s line:col, found in the same pass and before anything is counted. They win over every document.limit.* code, so a document holding both a bomb and a malformed merge reports the syntax error. <<: [] merges nothing and is accepted.
  • W is a structural upper bound, deliberately blind to key collisions: q: &q {<<: *p, k: 9} counts the merged k even though the local one displaces it. The reader never refines W downward.
  • The check runs before anything is materialized (D-19). It consumes yaml_rust2’s event stream in one pass with each anchor’s W memoized, so the work is linear in the input however often a definition is referred to; the events are buffered and only replayed into the tree builder once every candidate has passed. W and S are u64 with saturating arithmetic, and E is checked as each container completes, so no stored W exceeds max x S of the node in hand.
  • A self-referential anchor (a: &A {b: *A}, a: &A {<<: *A}, or a cycle through other definitions) has unbounded W and is rejected under the same code (D-20); no finite E is computed and nothing cyclic is built.

The maximum is configurable

#![allow(unused)]
fn main() {
use omnist::formats::yaml::{YamlReadOptions, read_yaml_with};

let options = YamlReadOptions::default().with_max_alias_expansion(200);
let doc = read_yaml_with("a: &x [1, 2]\nb: *x\n", &options).unwrap();
assert_eq!(doc.root().get("b").len(), 2);
}

read_yaml uses the default. YamlReadOptions::max_alias_expansion is a u32: 0 selects the default 50 (a zero or unset value never widens the limit), and a value above 10000 is refused by read_yaml_with (and by YamlReadOptions::validate) with an uncoded DocumentError rather than clamped. “No limit” is not a legal setting (D-10). The command-line tool always reads with the defaults.

YamlReadOptions::max_expanded_slots (the expanded size limit) follows the same convention: 0 selects the default 1,000,000, and a value above 10,000,000 is refused.

#![allow(unused)]
fn main() {
use omnist::formats::yaml::{YamlReadOptions, read_yaml_with};

let options = YamlReadOptions::default()
    .with_max_alias_expansion(100)
    .with_max_expanded_slots(5_000_000);
assert!(options.validate().is_ok());
let doc = read_yaml_with("a: &x [1, 2]\nb: *x\n", &options).unwrap();
assert_eq!(doc.root().get("b").len(), 2);
}

What the default accepts and rejects

Ordinary configuration reads far below the default: a <<: *defaults block reads E = 1.00 for the anchored defaults, 100 compose services each merging a 20-key defaults anchor read about 7, and a scalar anchor aliased 500 times is exactly 1. But a mapping that merges an n-key anchor and writes one key of its own has W = n + 2 over S = 3, so E = (n + 2) / 3: merging a 150-key anchor into such a mapping reads E ~ 50.67 and is rejected at the default, while 148 keys (E = 50.00) is accepted (only E > max rejects). Raise max_alias_expansion (up to 10000) for configurations that large.

It is a ratio, not a size cap

E bounds the expansion of each node relative to what that node writes. It bounds the whole document too: W(root) <= max x S(root). It does not bound a scalar-heavy document that is large without any aliasing, and the node cap below (Limits::max_nodes, 1,000,000 containers by default, reported as document.limit.nodes at $) is a separate, absolute size limit that the reader still applies after the check has accepted the input. Neither limit stands in for the other: a document can sit far under the node cap and still be refused for its ratio (a 100-key block aliased 100 times at the document root), and a document of unaliased scalars can pass the ratio check at E = 1.00 and still be refused for its size. Parse cost of the input text itself is linear and is paid before and independently of both. The ratio is not the only absolute bound on a document with aliases, though: see the next section.

Expanded size limit (D-22)

The ratio bounds amplification, not absolute size: a large document in which every container sits just under the maximum E passes D-18 and can still allocate gigabytes (omnist-spec#125 measured 30,000 such containers, about 3.2 million slots, at 2.5 GB in the Go port). So the reader also enforces a maximum expanded size: when the input contains at least one alias or merge key, W(root), the value slots the whole document materializes, greater than the maximum is rejected with document.limit.expanded-size at $; equal to it is accepted. The default is 1,000,000 slots (DEFAULT_MAX_EXPANDED_SLOTS), configured with YamlReadOptions::max_expanded_slots (0 selects the default; above 10,000,000, MAX_EXPANDED_SLOTS_CEILING, the spec’s recommended ceiling, is refused).

  • Alias-free documents are exempt, however large. An input with neither an alias nor a merge key (an anchor nothing refers to is not an alias) is read as a plain JSON or OML file of the same size would be: its size is the node cap’s and the caller’s own input-size bound’s business. This is a cliff by design: a plain file of two million slots passes, and adding one alias subjects it to the cap. A << merge key with no alias, such as <<: {a: 1}, also subjects the document to it. A quoted or explicitly tagged << is not decided by the spec and is not counted as a merge key here.
  • It runs after the ratio check. The check is made at the root once every candidate has passed D-18, and before anything is materialized, from the same single memoized pass with the same saturating counts: a saturated W(root) exceeds every maximum. An input that crosses both limits reports document.limit.alias-expansion; neither limit implies the other.
  • W is the structural count, blind to key collisions, so a document whose merged keys are overridden can be refused though it materializes fewer slots.
  • The node cap counts containers. This port’s materialization cap is 1,000,000 nodes (document.limit.nodes), the spec’s reference default, and a node is what D-9 counts: a mapping or a sequence. Keys and scalar values are not nodes, so a flat mapping of any number of scalar entries is one node, and the spec’s 1,000-service x 60-key compose example (W = 62,063) is accepted. An alias is charged for the containers it clones. The cap is YamlReadOptions::limits.max_nodes (with_limits). Through 0.6.0-alpha it was 100,000 and counted keys and values (DIV-11, omnist-rs#189).

Native temporal type on read, but no bare-time literal, and a looser input grammar than JSON

A bare YAML timestamp reads as a genuine Scalar::Date (no T) or Scalar::Datetime (has one) (issue #105) – never Scalar::Time: YAML’s own normalize_timestamp grammar always requires a date component, so there’s no bare-time literal to produce one from. YAML’s timestamp grammar is looser than JSON’s (space-separated date/time, single-digit month/day, a bare Z suffix, no zero-padding); this module normalizes any such spelling to the same canonical, zero-padded, T-joined ISO shape PyYAML’s own datetime.isoformat() would produce – so 2001-12-14 21:59:43.10 -5 round-trips to 2001-12-14T21:59:43.100000-05:00, not its original spelling. A timestamp-shaped string naming a calendar/clock value that doesn’t exist (2024-13-01) is a ParseError, matching PyYAML’s own construction-time failure.

On write, only a genuine Scalar::Date/Datetime writes bare; a plain Scalar::Str that merely looks like one always writes quoted, matching Python exactly (see Python divergences). A genuine Scalar::Time (e.g. from OML’s own bare-time grammar, or a schema-directed upgrade) has no native YAML spelling at all and always writes quoted.

Writing: escapes and temporals (C-14, E-37)

A string holding a character YAML 1.1 does not print is written double-quoted with an escape, never raw in a plain scalar: U+0000 to U+0008, U+000B, U+000C, U+000E to U+001F, U+007F and U+0080 to U+009F (except U+0085, which keeps \N) as \xXX, and U+FFFE and U+FFFF as \uXXXX, always with lowercase hex digits.

A time has no YAML 1.1 type (an unquoted 12:00:00 reads back as a sexagesimal integer and 12:00:00.5 as a float), so every time is written as a double-quoted string, seconds included ("12:00:00+01:00"), and check_yaml and write_yaml report format.temporal-stringified at its path (E-37). A datetime is written bare with the T separator and its seconds (2024-01-15T12:00:00).

Native NaN/Infinity – no lossy adjustment here (unlike JSON)

YAML’s float grammar has native .nan/.inf/-.inf tokens, so unlike write_json, write_yaml never substitutes null for a special float. The only adjustment check_yaml ever records is forcing double-quoted style for a string containing U+0085 (NEL), which PyYAML’s default styles would otherwise normalize away as a line break.

Legacy sexagesimal integers (H:M:S-shaped)

YAML 1.1’s implicit-int resolver also recognizes a colon-separated sexagesimal form – 12:00:00 resolves to Scalar::Int(43200), not a string. This module’s resolver folds each :-separated group as acc*60 + group (checked arithmetic; overflow reports the same out-of-range ParseError as an oversized plain integer), requires no leading zero on the first group, and constrains later groups to 0..=59 – so 01:20, 1:60, and 0:0:1 all stay plain strings (each violates one of those rules), matching PyYAML’s own grammar. Confirmed by omnist-rs issue #87, found while building the conformance harness against omnist-spec.

The sexagesimal float is read too (C-17): 1:30.5 is the number 90.5 (^[-+]?[0-9][0-9_]*(:[0-5]?[0-9])+\.[0-9_]*$, folded as acc*60 + group with the fraction on the last group). The spellings YAML 1.1’s core schema does not resolve stay strings: 0o17, 08, 09, 0:5, 1e3, 1.0e3.

Mapping keys are implicitly typed too (the “Norway problem”)

Every mapping key is run through the same implicit-type resolver as values, matching PyYAML: a key like on: is rejected (it resolves to Bool(true), not a string), and so is any other non-string-resolving key shape (int-, float-, sexagesimal-shaped, null-shaped). The rejection is a DocumentError (document.unlabeled-element) at the Document path of the mapping that holds the key ($ for the top-level mapping, $.a for a: {on: 1}, $.a.b deeper, and for a mapping inside a sequence the index appears only when the label repeats, E-10: $.a for a: [{on: 1}], $.a[1] for a: [{x: 1}, {on: 1}]; through 0.7.0-alpha it was $ at every depth), with a Python-parity message (e.g. object key True is not a string, object key 1.0 is not a string – including keeping the .0 on whole-number floats). Ordinary string keys, and non-boolean words like bare y/n, are unaffected. Confirmed by omnist-rs issue #88, found and fixed alongside #87 above.

Integer digit cap

Same 4300-digit cap as json.rs/oml.rs/toml.rs (Limits::max_int_digits, set through YamlReadOptions::with_limits; an over-long integer is a DocumentError document.limit.int-digits at its Document path), applied to a plain decimal integer scalar’s digit run before parsing; arbitrary-precision above that (see formats/json.md). The legacy sexagesimal fold (above) enforces the identical cap on its folded result, not any one group – issue #104: an unbounded BigInt fold with no such check would let a many-group literal build an arbitrarily large integer, a resource-exhaustion regression the fold’s old i64 overflow used to prevent as an unplanned side effect.

TOML

omnist::formats::toml::{read_toml, write_toml, check_toml}. Ported from ~/dev/omnist/omnist/formats.py’s read_toml/write_toml/check_toml; see omnist/src/formats/toml.rs’s module doc for the full detail.

#![allow(unused)]
fn main() {
use indexmap::IndexMap;
use omnist::document::{Doc, Value};
use omnist::formats::toml::{read_toml, write_toml};

let mut fields = IndexMap::new();
fields.insert("name".to_string(), Value::Str("Ada".to_string()));
fields.insert("age".to_string(), Value::Int(37));
let doc = Doc::of(&Value::Object(fields)).unwrap();

let text = write_toml(&doc, true, None).unwrap();
let doc2 = read_toml(&text).unwrap();
assert!(doc.eq_doc(&doc2));
}

No null – the one lossy TOML adjustment

TOML has no null at all. Writing a node that contains a null fails the write unconditionally with write.unsupported-value (spec sections 8.3.8 and 8.3.9), whatever strict says: dropping the field, as this port once did and recorded as a null.omitted warning, erased the edge’s existence with no trace on read-back (omnist-rs#160 retired that behaviour). The XML writer refuses a null the same way (spec C-10); see XML.

Integers are arbitrary precision; the digit limit counts decimal digits

A TOML integer is arbitrary precision here, in every radix (spec D-2). The limit is Limits::max_int_digits (default 4300; read_toml_with changes it), and it counts the decimal digits of the value (D-28), whatever the spelling: 0xFFF is 4095, four digits; underscores are separators, not digits. A value over the limit is a DocumentError with code document.limit.int-digits at the integer’s Document path, never a syntax error. A 3,600-digit hex literal is about 4,335 decimal digits, so it is refused at the default; a 4,301-digit binary literal is about 1,295, so it reads.

toml_edit holds an integer in 64 bits and refuses a longer one with no value to read. This reader works around that: it takes the byte range from the refusal, reads the literal itself (any radix, optional sign, underscores), swaps in a one-character stand-in of the same width so every other offset keeps its place, parses again, and puts the real value back by offset when it builds the Document. A decimal literal already longer than the limit in its own spelling is not converted at all; a placeholder stands in for it, so it is refused without paying the superlinear conversion. A syntax error elsewhere in the text still wins over a limit. The literals are found in one scan, so the cost stays linear however many there are.

Python’s tomllib exempts hex, octal and binary literals from CPython’s 4,300-digit string-conversion guard; this port does not, because D-28 counts the value’s decimal digits for every radix.

A float whose magnitude overflows (1e999, -1e999) reads as positive or negative infinity (D-29), by the same route.

Writing an oversized integer to TOML needs no workaround: the writer renders integers as plain digit text.

Nesting past about 80 levels

toml_edit has a recursion limit of its own, about 80 levels of table header, inline table, array or dotted key. TOML nested between about 81 levels and the configured maximum depth (200 by default) therefore fails with document.limit.depth at $ even though the configured limit has not been reached; the message names toml_edit as the cause. This is a limit of the parser library, not of the format or of the Document model, and raising max_depth does not lift it.

Native temporal types, at most six fractional digits

TOML has four first-class temporal literal forms (local date, local time, local datetime, offset datetime), stricter-shaped than YAML’s – toml_edit itself fully validates calendar/clock fields at parse time (2024-02-30, 25:00:00, and a +25:00 offset are all parse errors, not accepted-then-rejected-later). A fractional second holds at most six digits (the OML grammar’s 1*6DIGIT): zeros beyond the sixth are insignificant and dropped, but a non-zero seventh or later digit fails the read (00:32:00.9999999 is a parse.codec-syntax error) rather than being truncated as tomllib does (fail, don’t invent; through 0.11.0-alpha it was silently truncated). A numeric UTC offset is preserved exactly across read+write (no offset-erasure bug).

On read, toml_value_to_value constructs the real Value::Date/Time/ Datetime variant directly from toml_edit::Datetime’s own already- validated date/time fields (issue #105) – previously this discarded into a plain Value::Str. On write, only a genuine Date/Time/ Datetime variant is written as a native, unquoted TOML literal; a plain Scalar::Str that merely looks like a date always writes as a quoted string, matching Python’s write_toml exactly (a plain Python string that looks like a date, e.g. tomli_w.dumps({'a': '1979-05-27'}), also writes quoted). This resolves the shape-guessing divergence issue #99 first introduced for OML and issue #105 now closes here too – see Python divergences. A Time value carrying a UTC offset has no native TOML spelling at all (only datetime can carry an offset) and always writes double-quoted, with format.temporal-stringified reported at its path (E-37). Every writer writes a time’s seconds (12:00 is written 12:00:00) and keeps the offset and every fractional digit; a datetime uses the T separator.

XML

omnist::formats::xml::{read_xml, read_xml_with_schema, write_xml, check_xml}. Ported from ~/dev/omnist/omnist/formats.py’s read_xml/write_xml/check_xml; see omnist/src/formats/xml.rs’s module doc for the full detail.

XML needs exactly one document element, so (unlike JSON/YAML/TOML) an example document must be wrapped under a single top-level element.

#![allow(unused)]
fn main() {
use indexmap::IndexMap;
use omnist::document::{Doc, Value};
use omnist::formats::xml::{read_xml, write_xml};

// `age` is a `Value::Str`, not `Value::Int`: XML text carries no type
// information (see this doc's "Text stays untyped" note below) -- keeping
// it a string in the first place is what makes this round trip lossless.
let mut fields = IndexMap::new();
fields.insert("name".to_string(), Value::Str("Ada".to_string()));
fields.insert("age".to_string(), Value::Str("37".to_string()));
let mut root = IndexMap::new();
root.insert("person".to_string(), Value::Object(fields));
let doc = Doc::of(&Value::Object(root)).unwrap();

let text = write_xml(&doc, true, None).unwrap();
let doc2 = read_xml(&text).unwrap();
assert!(doc.eq_doc(&doc2));
}

Structural difference: interleaving-preserving, not grouped

Unlike the other three codecs, this module goes through Doc::from_raw/ Doc::to_raw (RawNode), not Doc::to_grouped – XML element order can interleave distinct labels arbitrarily (<b/><c/><b/>), which a JSON-shaped IndexMap can’t represent.

quick-xml: no advisory, no DTD/entity-expansion at all

cargo audit against this crate’s full dependency tree (29 crates, including quick-xml 0.41.0) on 2026-07-26 against the RustSec advisory database found zero matches – unlike TS’s port, which carried an unfixable fast-xml-parser advisory (omnist-ts#38). quick-xml also has no DTD/external-entity expansion support at all (only the five predefined XML entities are recognized) – XXE-safe by construction, not by configuration (Python’s read_xml needs defusedxml instead of the stdlib ElementTree for the same protection).

The data-XML profile: what the reader refuses

read_xml reads a deliberately narrow subset (omnist-spec’s data-XML profile) and refuses the rest, rather than ignoring it. Each refusal is a DocumentError at path $ whose code is:

Constructcode
any DOCTYPE declaration (refused on sight, even if nothing uses it)format.dtd-forbidden
an entity reference other than the five predefined ones, in text or in an attribute valueformat.entity-forbidden
text alongside child elementsformat.mixed-content

These are refusals, not syntax errors: the input is well-formed XML. The refusal is raised only after the whole document has proved well-formed, so malformed XML is always parse.codec-syntax (never misreported as a refusal). Numeric character references and the five predefined entities stay legal; comments, CDATA sections and processing instructions are inert.

Characters and character references on read (C-15, C-16)

XML 1.0’s Char production admits U+0009, U+000A, U+000D, U+0020 to U+D7FF, U+E000 to U+FFFD and U+10000 to U+10FFFF. A character outside it, written directly anywhere in the input (text, a CDATA section, an attribute value, a comment, a processing instruction, a name), is parse.codec-syntax, as is a numeric character reference that is malformed (&#;, &#x;, &#65 without its ;, &#X41;, a sign, a non-digit, a value too large for an integer) or names a code point outside Char (&#0;, &#1;, &#xB;, a surrogate, &#xFFFE;, &#xFFFF;, &#x110000; and above). The check covers an attribute value’s references too, although attributes are otherwise dropped. A valid reference reads as the one character it names. An encoding= pseudo-attribute in the XML declaration is ignored whatever it names (C-15): the bytes are read as UTF-8.

Illegal characters fail the write

XML 1.0 cannot represent a raw C0 control character other than tab, LF and CR (nor U+FFFE/U+FFFF). A string holding one fails write_xml with write.unsupported-value at that string’s path, strict or not; it is no longer replaced with U+FFFD.

Namespaces: a disclosed simplification

quick_xml runs in non-namespace-aware mode here; a namespaced tag’s local name is taken by stripping a lexical prefix: up to the last :, which coincides with Python’s ElementTree-based behavior for the common case (a declared, in-scope prefix) but does not resolve prefixes through xmlns declarations. Namespaces are outside this issue’s spec.

Text stays untyped until materialize

XML’s grammar carries no type information – <m>1</m> and <m>hi</m> are syntactically identical, a bare text node. read_xml builds every leaf as a plain string unconditionally; no int/float/bool inference happens at parse time.

Writing a non-string scalar (bool/int/float) to XML now honestly reports it: XML has no native typed literals, so it reads back as a string, not its original type (check_xml’s format.value-stringified adjustment; named value.stringified through 0.7.0-alpha, renamed to the code the spec’s section 8.3.8 names).

A date, time or datetime leaf is reported as format.temporal-stringified instead, never as format.value-stringified as well (E-5): a leaf takes one of the two codes, once, at the leaf’s own path (indexed when the label repeats). The text is the value’s ISO-8601 spelling with its seconds always written (12:00 is written 12:00:00, E-37).

null has no XML form

XML has no null token, and a childless element reads back as the empty string (<root><s/></root> is s holding "", not a null). A written null would therefore be indistinguishable from the different, valid Document holding the empty string, so the writer fails (spec C-10): write_xml returns a WriteError with code write.unsupported-value and the Document path of the null leaf, E-10 indexed ($.root.item[1] for the second of three item edges), whatever strict says. check_xml previews the same refusal as an error-severity write.unsupported-value entry.

Through 0.8.0-alpha this port wrote an empty element and recorded a null.omitted warning, a code the spec’s table never had. The code is gone with the behaviour (0.9.0-alpha); a script that matched null.omitted, or relied on a null becoming <a />, must change. TOML’s null fails the same way.

Schema-guided pretyping (spec §2.2 / issue #114)

Because XML text is untyped and materialize strictly rejects coercing plain strings to boolean/integer/number, [read_xml_with_schema] performs schema-guided pretyping on the raw XML tree before materialization (spec §2.2 / issue #114).

An integer leaf with more decimal digits than the D-9 limit (Limits::max_int_digits, default 4,300; D-28 counts the digits of the value) is refused with document.limit.int-digits at the leaf’s Document path ($.r.n, or $.r.n[1] when the label repeats, E-10), not left a string for materialize to reject as materialize.inexact-conversion (omnist-rs#204). A number leaf is exempt from the limit however long its integer part (D-28): past binary64 it reads as +Infinity/-Infinity (or a zero on underflow, D-29), never as a string and never as materialize.inexact-conversion (through 0.11.1-alpha a number leaf over the limit stayed a string).

#![allow(unused)]
fn main() {
use omnist::formats::xml::read_xml_with_schema;
use omnist::osd::parse_schema;

let schema_osd = r#"
record Address  { "street": string, "city": string }
record LineItem { "sku": string, "qty": integer, "price": number }

record Order {
    "id":           string,
    "status":       string,
    "total":        number,
    "address":      Address,
    "items" [1,]:   LineItem,
    "coupon" [0,1]: string,
}

record Root { "order": Order }
root Root
"#;
let schema = parse_schema(schema_osd).unwrap();

let xml = r#"<order>
  <id>A1</id>
  <status>shipped</status>
  <total>29.97</total>
  <address><street>1 Main</street><city>London</city></address>
  <items><sku>W</sku><qty>3</qty><price>9.99</price></items>
  <items><sku>G</sku><qty>1</qty><price>9.99</price></items>
</order>"#;

let doc = read_xml_with_schema(xml, &schema).unwrap();
assert!(schema.validate(&doc.root()).ok());
}

OML

omnist::oml::{read_oml, write_oml}. Ported from ~/dev/omnist/omnist/oml.py (issue #10); see omnist/src/oml.rs’s module doc for the full detail. OML (Omnist Markup Language) is omnist’s own native format: every Document round-trips through it exactly, with no adjustment ever needed – unlike JSON/YAML/TOML/XML, each of which has at least one lossy corner (see their own pages).

#![allow(unused)]
fn main() {
use indexmap::IndexMap;
use omnist::document::{Doc, Value};
use omnist::oml::{read_oml, write_oml};

let mut fields = IndexMap::new();
fields.insert("name".to_string(), Value::Str("Ada".to_string()));
fields.insert("age".to_string(), Value::Int(37));
let doc = Doc::of(&Value::Object(fields)).unwrap();

let text = write_oml(&doc.to_raw(), 2).unwrap();
let raw2 = read_oml(&text).unwrap();
let doc2 = Doc::from_raw(raw2).unwrap();
assert!(doc.eq_doc(&doc2));
}

Core vs. Extended

read_oml implements the OML-Core grammar in full, plus the OML-Extended raw-string ('...') and triple-quoted multiline-string ("""...""") spellings on read only. write_oml only ever emits OML-Core double-quoted strings, matching the Python reference exactly – reading back a raw-string or triple-quoted literal never round-trips to that same spelling, only to an equivalent Core one.

RawNode, not Value – the only codec (with XML) that needs it

Value::Object’s IndexMap can only represent “repeated label” as a contiguous run (an array under one key). OML must round-trip arbitrary interleaving of repeated labels losslessly – its whole reason for existing – so read_oml/write_oml work over RawNode’s literal edge list, never Value.

Temporal literals: shape-checked, then validated, then canonicalized

date/time/datetime literals are recognized by shape in this module’s own scanner, then validated by the same shared crate::schema::is_iso_date/is_iso_time/is_iso_datetime checks schema.rs and every other codec use. A recognized-but-invalid literal (2024-02-30) is a ParseError, never silently accepted. A valid literal becomes a genuine Scalar::Date/Time/Datetime (issue #105) – the scanner threads which of the three kinds it matched (TemporalKind) through to the parser so it constructs the exact right variant, not a generic string.

time/datetime literal text is canonicalized on read (issue #90, fixed while building the conformance harness against omnist-spec): a missing :SS is filled to :00, and an under-padded fractional-second component is zero-padded to 6 digits. A time or datetime built through the API with a non-zero digit past the sixth is written as a quoted string and reported as format.temporal-stringified (the literal’s 1*6DIGIT cannot spell it); zeros past the sixth are dropped. date literals have no optional grammar components and pass through unchanged. This means a bare 12:00 reads as Scalar::Time("12:00:00"), not "12:00" – see the corrected note in Python divergences for what changed from this port’s earlier, stronger byte-for-byte claim.

Bare vs. quoted on write: real variant, not shape-guessing

A leaf writes bare (no quotes) only when it genuinely holds a Scalar::Date/Time/Datetime – never by guessing from a Scalar::Str’s shape (issue #99). The pre-#99 writer wrote any date/time/datetime-shaped string bare, regardless of provenance: a plain JSON string like "2024-01-01" got silently promoted to a genuine OML temporal literal on write, corrupting it on the next read (a different Document, per ch.4’s grammar). Confirmed live and fixed.

Issue #99 fixed this with a RawNode::TemporalLeaf write-hint tag layered on top of Scalar::Str, since Scalar had no temporal variant of its own at the time. Issue #105 gave Scalar real Date/Time/Datetime variants, making that tag redundant – it’s been removed, and the writer now just matches on Scalar’s own variant directly. The two real sources that produce a genuine temporal variant are unchanged:

  • OML’s own bare-literal grammar. read_oml’s parser constructs a Scalar::Date/Time/Datetime for a genuinely-read bare literal token; an ordinary quoted string (however it’s shaped) stays a plain Scalar::Str.
  • Schema-directed materialize. Upgrading a field to a Date/Time/Datetime-kinded schema constructs the real variant too, so a materialized document writes its temporal fields bare through OML.

Temporal output (E-37, OML-30)

A time or datetime is always written with its seconds (12:00 is written 12:00:00), the offset after the seconds and any fraction, and a datetime with the T separator. The value is never altered.

Integer digit cap and i64

Same 4300-digit cap as json.rs/yaml.rs/toml.rs (Limits::max_int_digits, changed with read_oml_with), and the same i64-backed representational ceiling – see formats/json.md. An over-long integer is a DocumentError (document.limit.int-digits) at the integer’s Document path through read_oml_with; read_oml keeps reporting it as a ParseError at the text position. The depth and node limits are document.limit.depth and document.limit.nodes at $; a node is a brace subtree or the document’s own edge list, never a scalar.

--arrays is not yet implemented

Python’s write_oml supports an arrays=True mode that collapses runs of same-label edges into [...] array syntax. This port’s write_oml has no such parameter yet – separate, not-yet-ported library work tracked by the CLI’s own --arrays “not supported yet” error (see cli.md).

Python divergences

A single index of every disclosed, live-checked behavioral divergence from the Python reference (~/dev/omnist) found across this port’s merged PR history. Each entry names what Python does, what this port does, the reasoning from the PR that introduced it, and whether it’s a permanent representational choice or something revisitable later.

This page is an index, not a replacement for docs/limitations.md or the per-format pages under docs/formats/ – those already carry the full detail for the i64/temporal/any gaps. Where an entry below is already documented there, this page states it briefly and links out rather than repeating it.

Why this page exists alongside omnist-spec’s divergence ledger (Sec9): the ledger tracks current, named, cross-port divergences (D-1, D-2, …) from the spec – behavior every implementation must agree on. This page tracks something the ledger doesn’t: divergences from the Python reference implementation specifically, each cited to the exact PR that introduced or resolved it, with the live cross-check that found it. Most of these are permanent, disclosed representational choices (i64 vs arbitrary precision, ASCII-only XML digit coercion) that are conformant with the spec on both sides – not spec violations, so they have no ledger entry at all. Check the ledger for current cross-port status; check this page for why this port’s own output differs from Python’s on a specific input, and which PR made that call.

Integer representation: i64 vs Python’s arbitrary-precision int

Python: int is arbitrary precision; a decimal literal is only rejected past sys.set_int_max_str_digits’s 4300-digit cap. Rust: document::Scalar::Int is i64 (max ~19 significant decimal digits), so any literal over that – even one comfortably under the 4300-digit cap – is a ParseError (“out of range for a 64-bit integer”).

Origin: PR #5 (document.rs) chose i64 for the Document model itself and, as a direct consequence, dropped Python’s _check_int_digits guard entirely rather than ship “permanently-dead code with no reachable branch to test” – the guard exists in Python to stop a superlinear str() conversion of a huge int, which can’t happen when the type tops out at 19 digits. PR #11 (oml.rs) then had to reintroduce a 4300-digit cap anyway, but at the text-scanning layer (MAX_INT_DIGITS in oml.rs) rather than the Document model, because OML source text is “the one place an arbitrarily-long digit run can actually reach the parser” before the i64 conversion fails. PR #17 (json.rs) verified live against CPython’s json.loads that the same 4300-digit cap fires independently of the 64-bit ceiling, then applied the identical text-layer cap. PR #23 (xml.rs) found a genuinely different Python behavior for this same gap: Python’s XML _coerce falls through a 20-4300-digit numeral to a float rather than erroring, so the Rust port matches that specific control flow (falls through to Scalar::Float) instead of raising.

Permanent: yes. It’s a direct, disclosed consequence of #5’s Document-model decision (Scalar has exactly five variants, Int is i64), applied consistently rather than special-cased per format. See limitations.md for the format-by-format table.

TOML’s hex/octal/binary digit-cap

Python: tomllib applies the 4300-digit cap to decimal literals (same sys.set_int_max_str_digits mechanism as JSON), but exempts hex/octal/binary (power-of-two-base) literals from that cap entirely – confirmed live: tomllib.loads("x = 0x" + "f"*10000) parses with no error at all.

Rust: applies the spec’s rule (D-28) to every radix: the limit counts the decimal digits of the integer’s value, so a 3,600-digit hex literal (about 4,335 decimal digits) is refused with document.limit.int-digits and a 4,301-digit binary literal (about 1,295) reads. toml_edit itself holds an integer in 64 bits, so the reader works out a literal it refuses and puts the value back (see TOML). The difference from Python is only that Python’s radix exemption is not replicated.

Permanent: yes. It follows the spec, not Python.

Date-shaped strings as native temporal literals (TOML/YAML) – resolved by issue #105

Formerly: Scalar had no temporal variant, so toml.rs/yaml.rs could not distinguish “a string that happens to look like a date” from “a value that was genuinely read as a date,” and shape-guessed: any Scalar::Str whose contents matched the temporal shape check wrote back as a native temporal literal, unlike Python, which only ever promotes a real datetime object.

Now: issue #105 gave Scalar/Value real Date(String)/ Time(String)/Datetime(String) variants. toml.rs/yaml.rs construct the real variant directly from toml_edit’s own already-validated date/time fields (TOML) or from normalize_timestamp’s output (YAML) – never by shape-guessing a Str – and write only a genuine temporal variant as a native literal; a plain string that merely looks like a date now stays a quoted string on write, matching Python exactly. This divergence is resolved, not permanent – see limitations.md for what temporal kinds still don’t do (arithmetic).

Bare time literal round-tripping (OML, PR #11; corrected by issue #90)

Python: parses a bare time literal like 12:00 into a real datetime.time object, then always writes it back via isoformat(), which normalizes to 12:00:00 (seconds always present).

Rust, as of PR #11: Scalar stored the literal’s exact source spelling and round-tripped it byte-for-byte, so 12:00 stayed 12:00 – characterized at the time as “a stronger round-trip guarantee than Python’s own, not a bug against Python’s docs,” no upstream issue filed.

Rust, as of issue #90 (found while building the conformance harness against omnist-spec): that stronger guarantee was itself a spec violation – omnist-spec’s OML grammar treats time/datetime literal text as canonicalized on read, the same way Python’s isoformat() normalizes it. read_oml’s scanner now fills a missing :SS to :00 and zero-pads under-padded fractional seconds to 6 digits, so 12:00 now reads as Scalar::Str("12:00:00") – matching Python’s output exactly, not diverging from it. date literals (no optional grammar components) are unaffected.

Permanent: yes, but now in the sense of “matches Python,” not “diverges from it” – this entry is kept for history; the actual current behavior is not a divergence.

OML’s UTC-offset preservation (PR #11) / TOML’s identical case (PR #21)

Both codecs preserve an explicit UTC offset (-07:00/+07:00) exactly on round-trip, and both normalize a bare Z suffix to +00:00 rather than preserving Z literally – a normalization choice inherited from yaml.rs’s normalize_timestamp, applied consistently across format codecs rather than re-decided per format. Not a divergence from Python’s observable output; noted here because it was explicitly cross-checked (the omnist-ts#51-derived regression class) in both PRs.

infer()’s API shape: one Python kwarg vs two Rust functions

Python: infer(samples, root_name, allow_any=False) – a single function, allow_any is a keyword argument.

Rust: infer::infer(samples, root_name) always behaves as Python’s allow_any=False path (any ambiguous field is a SchemaError); infer::infer_with_report(samples, root_name, allow_any) is a second function that also accepts allow_any: true and returns every recorded AnyFallback alongside the schema, mirroring Python’s infer_with_report/AnyFallback pair rather than its single infer(..., allow_any=...) surface.

Origin: PR #15 (infer.rs) scoped any out of inference entirely, leaving infer with no allow_any parameter at all – an unforced scoping choice, not something the deferred any governance question required (see limitations.md). Issue #29 later flagged that scoping-out as a mistake and tracked porting any for real. PR #33 (closing #29) fixed it by porting allow_any as a second function, infer_with_report, rather than adding an optional parameter to infer itself – an intentional API-shape split from Python’s single-function-with-kwarg design, flagged during PR #33’s review as a deliberate divergence worth recording rather than silently carrying forward.

Revisitable: yes, in principle – collapsing back to a single function with a defaulted parameter is a compatible API change if ever desired, but the two-function split is deliberate for now.

XML: scalar coercion narrowing (PR #23)

Python: _coerce’s rules, live-checked directly rather than assumed. Two narrowings this port discloses:

  • A 20-4300-digit numeral (too big for i64, still under the security cap) falls through to Scalar::Float, matching Python’s own int-then-float control flow – covered under the general i64 gap above, not a separate issue.
  • Unicode decimal digits (e.g. Arabic-indic digits) are not recognized by this port’s coercion – ASCII-digit-only, a narrowing from whatever Python’s coercion accepts.

Permanent: yes, treated as a disclosed narrowing rather than a bug.

XML: DTD/XXE safety by construction vs Python’s defusedxml dependency

Python: read_xml requires defusedxml instead of the stdlib ElementTree, specifically to guard against XXE/DTD-expansion attacks – an explicit dependency choice to close a real vulnerability class.

Rust: uses quick-xml 0.41.0 (tokenization only, no serde feature), which has no DTD/external-entity expansion support at all. XXE safety is a structural property of the crate, not a guard that has to be separately verified or maintained.

Not a behavioral divergence in observable output – both are safe against XXE – but a divergence in how that safety is achieved, worth recording because it removes an entire dependency Python’s implementation needs. Permanent (a consequence of the crate choice in PR #23).

YAML: bool tag spellings and calendar-invalid timestamps (PR #19, bugs found and fixed, not divergences)

Two items from PR #19 are not divergences from Python – they were bugs in this port’s own WIP checkpoint, found via live cross-check against PyYAML and fixed to match Python exactly, before merge:

  • Explicit !!bool tag construction was initially too narrow (only true/false spellings); PyYAML’s bool_values accepts yes/no/on/off case-insensitively regardless of implicit vs explicit tagging. Fixed to match.
  • A timestamp-shaped scalar naming an invalid calendar/clock value (2024-13-01, 2024-02-30, hour 25, tz +25:00) initially fell back silently to a plain string; PyYAML actually raises and fails the whole document. Fixed to raise ParseError.

Listed here for completeness (issue #39 asked for both), but neither is a live divergence today – both match Python’s behavior as of PR #19’s merge.

Not a divergence: the any-type gap and the i64/temporal gaps documented elsewhere

Two structural gaps referenced above are covered in full in limitations.md rather than repeated here:

  • The any-type scoping gap (deferred pending the sibling omnist project’s openness decision, per issue #29 and PR #33) – limitations.md.
  • The i64 representational ceiling, per format – limitations.md.
  • Temporal kinds having no arithmetic (a real variant, but an opaque canonical string, not a chrono/time value) – limitations.md.

Summary table

DivergencePermanent or revisitableOrigin PR
i64 vs arbitrary-precision intPermanent#5, #11, #17, #23
TOML hex/octal/binary uncapped in Python, capped herePermanent#21
Date-shaped string becomes native literal on write (TOML/YAML)No longer a divergence – resolved by #105’s real temporal variants#19, #21, #105
Bare time literal round-trips exactly instead of normalizingNo longer a divergence – corrected by #90 to match Python#11, #90
infer()/infer_with_report() split vs single allow_any kwargRevisitable#15, #33
XML coercion: over-i64 numeral falls to floatPermanent#23
XML coercion: ASCII-only digit recognitionPermanent#23
XML XXE-safety by construction (quick-xml) vs defusedxmlPermanent (crate choice)#23

Conformance against omnist-spec

This port has its own conformance-test harness (tools/conformance/) against omnist-spec, the language-agnostic upstream specification. It vendors omnist-spec as a pinned git submodule (vendor/omnist-spec, currently commit 7575727, the v0.36.0-beta tag) and runs entirely against this crate’s own library code – it does not depend on the Python or TypeScript ports’ implementations.

Two tracks, both wired into CI as a dedicated conformance job (.github/workflows/ci.yml), gated on real fail count only, never on skip count, per the spec’s section 8.5.5 reporting rule:

  • Track 1 (vendor/omnist-spec/conformance/fixtures/, directory-per-fixture, 11 operations): 19 passed, 0 failed, 0 skipped.
  • Track 2 (vendor/omnist-spec/test-suite/, JSON-vector suite, 14-operation vocabulary): 765 passed, 0 failed, 28 skipped (of 793 vectors), diagnostics compared as (path, code) sets (section 8.5.2), not in code-agnostic mode. The runner exits non-zero on any failing vector and never on skips (E-22); it has no list of tolerated failures.

Neither track has a fail. Run it yourself:

cargo run -p conformance --bin self-test
cargo run -p conformance --bin runner
cargo run -p conformance --bin vector_runner

Every Track 2 skip, and why

All 28 skips are one spec category, E-20 “not yet implemented”; none is an E-21 documented divergence. The 6 document-model/limits vectors were skips through 0.6.1-alpha (304 / 0 / 34); the limits became runtime-configurable in 0.7.0-alpha and they now run and pass (310 / 0 / 28).

Adopting v0.36.0-beta (0.12.0-alpha). The suite grew from 731 to 793 vectors and the pin moved from v0.35.0-beta. Before any change the runner reported 757 pass / 8 fail / 28 skip, and the 8 were: 3 bare JSON NaN, Infinity and -Infinity tokens read as numbers (C-19, DIV-45); 3 nan-is-a-number vectors the runner failed because it compared NaN with == (E-33, DIV-47); and 2 OML 4,301-digit integer vectors reported at the text position 1:4 instead of $.a (D-28, DIV-46: the runner called read_oml, whose signature keeps a position; it now calls read_oml_with). Also fixed, with port tests only: the CLI --json payload carries the code and Document path of any document-level error (DIV-46), and an XML number leaf of any length reads as an infinity past binary64 (D-28, D-29, DIV-42). After the adoption: 765 / 0 / 28; the 28 skips are the same OSD-OML extension vectors. Track 1 stays 19 / 19.

Bug wave after v0.35.0-beta (0.11.1-alpha). The pin does not move: the suite stays 731 vectors (703 / 0 / 28). The wave fixes what the vectors cannot pin (an XML integer pretyped over the digit limit, a TOML or YAML fraction read past six digits, a multi-megabyte radix integer), each with the port’s own tests.

Adopting v0.35.0-beta (0.11.0-alpha). The suite grew from 522 to 731 vectors and the pin moved from v0.34.0-beta. Before any change the runner reported 642 pass / 61 fail / 28 skip, and the 61 were: 18 label-quoting path vectors (E-36: a Document path printed a label raw), 19 temporal-writing vectors (E-37: the seconds field dropped in every format, no format.temporal-stringified for a TOML time with an offset or for a YAML time, and a YAML time written as an unquoted plain scalar), 17 XML vectors (C-15, C-16: a character outside XML 1.0’s Char and a malformed or out-of-range numeric character reference were accepted), 6 YAML escape vectors (C-14: a raw control character inside a plain scalar, and the U+007F, U+0080 to U+009F and U+FFFE/U+FFFF escapes) and 1 YAML sexagesimal-float vector (C-17). After the adoption: 703 / 0 / 28, and the 28 skips are the same OSD-OML extension vectors as before. Track 1 stays 19 / 19.

Adopting v0.34.0-beta (0.10.0-alpha). The suite grew from 367 to 522 vectors (the C-12, C-13, D-27, D-28, D-29, E-5 and E-35 vectors, among others) and the pin moved from v0.33.0-beta. Before any change the runner reported 467 pass / 27 fail / 28 skip, and the 27 were: 4 TOML number vectors (an integer beyond 64 bits and 1e999, D-2, D-28, D-29), 6 YAML duplicate-key vectors (C-12), 2 !!merge merge-key vectors (D-27), 6 !!omap/!!pairs/!!set vectors (C-13), 4 XML temporal leaves reported as format.value-stringified instead of format.temporal-stringified (E-5) and 5 OML compact vectors (E-35: the runner dropped the compact key and compared the expanded layout). Track 1 now compares the lint code exactly (omnist-rs#200). After the adoption: 494 / 0 / 28, and the 28 skips are the same OSD-OML extension vectors as before. The runner fails a vector that sets compact on a format other than OML.

Adopting v0.33.0-beta (0.9.0-alpha). The suite grew from 338 to 367 vectors (7 repeated-label path vectors, 10 input-size vectors, 7 OML-29 vectors, 5 XML null vectors) and the pin moved from v0.28.0-beta. Before any change the runner reported 318 pass / 21 fail / 28 skip, and the 21 were: 5 repeated-label paths (E-10: the first occurrence carried no index, DIV-16), 7 OML-29 (a separator after the colon, DIV-20), 5 over-cap input-size vectors (D-23, DIV-17) and 4 XML null writes (C-10, DIV-23). The 5 at-cap input-size vectors passed falsely at that point, because the runner ignored declared_max_input_bytes: it passed the vector against the port’s own default, which E-20a forbids. The runner now passes the declared maximum through Limits::max_input_bytes, and a vector carrying any other declared_* key, or one the runner does not honour on its operation or format, is an E-20 skip rather than a run against the default. After the adoption: 339 / 0 / 28, and the 28 skips are the same OSD-OML extension vectors as before. Two further rules have no vector: C-9 (a writer refuses a string with no UTF-8 encoding) is vacuous here, see Limitations, and the two OML reader fixes of omnist-rs#193 and #194 are pinned by unit tests only (no vector covers them).

Earlier history: (v0.28.0-beta adds no vectors (still 338): its four programmatic-schema rules, S-8’s $ path, S-22, S-23 and OSD-16/S-24, are pinned by no vector (DIV-5), only by omnist/tests/spec_v028.rs; Track 2 stays 304 / 0 / 34. v0.27.0-beta (338 vectors) added 7 alias-expansion vectors for the empty merge sequence <<: [] (D-18a); the port already merged nothing for it, so all 7 passed with no code change. v0.26.0-beta (331 vectors) grew alias-expansion.json to 35 vectors (D-18a, D-22 and the malformed-merge syntax errors); all run and pass, with declared_max_alias_expansion and declared_max_expanded_slots passed through YamlReadOptions for the vectors that carry them. Before this port adopted it the baseline was 289 pass / 8 fail / 34 skip: the anchored merge carrier and the alias to a merge sequence were wrongly rejected, 4 expanded-size vectors parsed OK, and a merge after a bomb and a merge sequence of sequences were not reported as syntax errors. v0.25.0-beta (312 vectors) added the 16 D-18 alias-expansion vectors, which now all run and pass with the declared maximum passed through YamlReadOptions::max_alias_expansion, and the 4 E-32 line:col placeholder vectors, which pass: a parse.codec-syntax expectation whose path is the placeholder is satisfied by the same code at a well-formed text position inside the input, and every other path is still compared byte for byte. Before this change, with the alias skip removed and nothing implemented, the baseline was 266 pass / 12 fail / 34 skip: the 8 alias vectors that expect a rejection and the 4 placeholders. v0.22.0-beta added 14 vectors (287 in all): 10 OML-26 with-a-separator vectors, the OML-25 scalar counterpart, OML-26’s negative control and 2 E-28 code-point column vectors, all passing; before this port adopted OML-26 and E-28 the baseline was 238 pass / 9 fail / 40 skip, the failures being 7 OML-26 vectors and the 2 column vectors, see the CHANGELOG. The suite grew from 249 to 273 vectors across v0.20.0-beta and v0.21.0-beta – 5 OML-26/27, 4 OSD-15 canonical-escaping and 1 S-21 infer vector at v0.20.0-beta, then 14 new bytes_hex D-14 vectors at v0.21.0-beta – and this port now runs every one of them for real, including bytes_hex through the CLI, its byte-oriented entry point, per E-27; see “Byte inputs (D-14, E-27)” below. The 4 OSD-15 vectors were already passing before the v0.21.0-beta work landed – the writer was already correct; see below.) Each skip’s reason is generated by tools/conformance/src/bin/vector_runner.rs and checked against the vector’s own input by the every_skip_reason_is_true_for_its_vector test, so it cannot drift from what is actually skipped. A vector whose actual error lacks a structured code or path FAILS; it is never skipped for lack of one.

  • 28 extensions-osd-oml/* vectors. The OSD-OML extension operations (parse_schema_oml, schema_from_document, schema_to_document, write_schema_oml) are not implemented; tracked in omnist-rs#175.

Where this port’s real ceiling differs from Python’s/TypeScript’s –

not implied parity

  • Divergence ledger D-6 (integer/number-kind-collapse) is TypeScript-only and does not apply to this port – confirmed empirically, not assumed: Scalar::Int(i64)/Scalar::Float(f64) are separate enum variants here, unlike TypeScript’s shared number, so the collapse D-6 describes structurally cannot happen in Rust.
  • This port’s ParseError { line, col, code, message } is structured (unlike TypeScript’s message-only ParseError), which let most syntax-failure vectors run for real here instead of blanket-skipping – a genuinely favorable per-language difference, found empirically while building the harness, not assumed going in.
  • Diagnostics are compared as (path, code) sets (section 8.5.2), the strict mode. Up to spec v0.9.1-beta this runner compared paths only, which is how vectors reported green while the code was wrong (infer and extract failures were not even compared, and extract reported a field path where the spec wants the record). Every error type now carries what the taxonomy needs: ParseError { line, col, code, message }, SchemaError { path, code, message }, DocumentError { path, code: Option<_>, message }, WriteError { path, code, .. }, and WriteReport adjustments. Diagnostics this port cannot yet emit exactly are listed in omnist-rs#182.

Byte-order marks (D-15, D-21)

One leading U+FEFF is stripped on every read surface (OML, OSD, JSON, YAML, TOML, XML); a second is rejected at 1:1 (parse.unexpected-token on OML and OSD, parse.codec-syntax on the four codecs); a U+FEFF anywhere else is ordinary content. The stripping lives in exactly one helper (omnist/src/bom.rs) that every reader calls before it does anything else, so no reader or library strips a second one. Writers never emit one. Before this change (measured, not assumed): OML stripped one and rejected a second at 1:1; TOML and XML stripped one (inside their libraries) but rejected a second at the wrong position (1:4); JSON and OSD rejected even a single mark; YAML kept every mark as part of the first key, so one mark silently renamed the key and two were accepted. Two tests guard it: one scans every tracked text file’s bytes for a raw EF BB BF (the mark is written as \u{FEFF} in source, never raw), and omnist/tests/bom.rs exercises all six readers and every writer. The one shape YAML loses under D-21 is an unquoted first key that begins with the mark; the YAML writer quotes any string that starts with one, so a Document this port writes always reads back.

Byte inputs (D-14, E-27)

New in v0.21.0-beta: a read-side vector (parse, parse_schema, parse_schema_oml) may give its input as bytes_hex (lowercase hex, two digits per byte) instead of text, so D-14 – reject invalid UTF-8 with parse.invalid-encoding at 1:1, before the BOM strip and doubled-BOM check – can finally be pinned by a vector; a JSON-vector text field cannot hold ill-formed bytes, so the rule was untestable before this.

This crate’s readers all take &str, which is always already-valid UTF-8 by construction (omnist-spec section 2.5 lets a string-typed entry point treat its input as decoded), so the check has to run earlier, on real bytes. omnist-cli is this port’s byte-oriented entry point – it reads a file or stdin – so it is where D-14 is enforced: omnist_cli::decode_input decodes strictly and, on failure, returns the fixed diagnostic (1:1, parse.invalid-encoding), never a lossy repair. Every CLI command that reads document, OML or OSD input (format, convert, check, validate, infer, every schema subcommand, and --schema) goes through it.

The vector runner (tools/conformance/src/bin/vector_runner.rs) presents a bytes_hex vector’s bytes to that same entry point – omnist-cli’s read_document_bytes/read_oml_bytes/parse_schema_bytes, added as a small public surface on the omnist-cli crate for exactly this – never by decoding with U+FFFD replacement and running the result through the ordinary &str reader, which the spec calls out by name as the one wrong way to run these vectors. tools/conformance/Cargo.toml depends on omnist-cli for this; it is an in-process function call, not a spawned subprocess, since Track 2 already links against the library crates directly. A text vector on these three operations still goes straight to the &str reader, unchanged.

All 14 bytes_hex vectors pass: the 8 invalid-UTF-8 ones (a truncated sequence, an overlong encoding, a lone continuation byte, an encoded surrogate, a byte above the Unicode range, one pinning the offset-zero case and one pinning line 2 – both still 1:1) and the 6 valid multi-byte controls, proving the check does not also reject legitimate multi-byte text. omnist-cli’s own test suite (omnist-cli/tests/cli.rs) drives the same check directly through the compiled binary over stdin and files, on all six surfaces, plus the D-21 boundary (a truncated BOM is D-14; a valid doubled BOM is D-21, not D-14) and the “no panic on hostile bytes” sweep described below.

Canonical OSD escaping and the unwritable label (OSD-15, OSD-14)

New conformance vectors in v0.21.0-beta, but OSD-15’s rule – a backslash is written \\, a double quote \", nothing else – was already satisfied by this port’s omnist::osd::quote_label before this sweep. A draft of this document previously claimed a fix that did not happen; verified directly (git diff origin/main -- omnist/src/osd.rs at the v0.19.0-beta -> v0.21.0-beta boundary never touches quote_label, and the pre-sweep code was measured against the four new osd-grammar/canonical-output/label-* vectors on its own: 219 pass / 14 fail / 40 skip, with all 14 failures being bytes_hex D-14 vectors and zero being OSD-15 vectors).

OSD-14 (a field label with a C0 control character has no OSD spelling) has no conformance vector – the suite’s schema field is always OSD text, and a schema with such a label has none – so to_osd is unit- and property-tested directly instead (omnist/src/osd.rs, omnist/tests/fuzz.rs): a schema built programmatically with a control-character label fails with write.unsupported-value at the record’s path, and a proptest property (osd_write_read_round_trips_for_arbitrary_valid_labels) checks parse_schema(to_osd(s).unwrap()).unwrap() == s for arbitrary non-empty labels excluding [/] and C0 controls, generated with a distribution that frequently includes backslashes, quotes and non-ASCII text (not just plain ASCII words), so it actually exercises OSD-15’s escaping on every run. to_osd returning Result rather than String is a breaking API change for an alpha crate (see the crate’s own CHANGELOG); every caller in this workspace (the CLI, examples, tests, both conformance runners) was updated.

The conformance harness’s own comparison bugs (found by independent review)

Two real gaps in tools/conformance itself, found by an independent review of this PR and fixed with mutation evidence, not just re-reading the code – and one over-correction in the first attempt at fixing them, caught by a second independent review and corrected in turn:

  • run_parse_schema (Track 2) never compared expect.schema. A vector giving expect.schema alongside a successful parse_schema only ever checked ok: true, never the schema’s canonical text – proven by deleting all escaping from quote_label in a scratch copy and observing no change in the reported pass count. Fixed: when expect.schema is present, the parsed schema is rendered through to_osd(&schema, Some(4)) (the canonical pretty form) and compared byte-for-byte, per omnist-spec section 8.5.3.

  • Track 2’s schema-output comparisons (normalize/prune/extract) were structural, not byte-for-byte, when section 8.5.3 requires byte-for-byte. referee::compare_schema’s "exact" mode re-parsed both OSD texts and compared Schema == Schema, blind to any writer formatting difference that doesn’t change the parsed structure – proven by widening to_osd’s pretty-mode indent from one space to three and observing both conformance tracks stay green.

    The first fix over-corrected: it made "exact" itself byte-for-byte, which is right for Track 2 but wrong for Track 1. Track 1’s own spec (vendor/omnist-spec/docs/conformance-harness.md section 4’s reference pseudocode, and section 6’s self-test fixtures – 01-schema-exact-equal-different-field-order, 07-schema-exact-equal-different-env-declaration-order – one of them literally titled “two structurally identical schemas, written with different field order and different indentation, compare equal under exact mode”) defines "exact" as structural, deliberately, and Track 1’s own runner.rs self-test fixtures failed for real once "exact" was redefined globally. A second review caught this before it shipped.

    The corrected design: referee::compare_schema now has three modes. "exact" stays structural (Schema == Schema), used only by Track 1’s runner.rs, matching docs/conformance-harness.md. A new "canonical" mode does the real byte-for-byte comparison Track 2 needs, used only by vector_runner.rs for normalize/prune/extract/parse_schema’s expect.schema (section 8.5.3 explicitly requires it for normalize and parse_schema, and extract inherits it by delegating its final step to normalize, per docs/06-schema-algebra.md section 6.9). "isomorphic" (infer) is unaffected in either track – correctly structural, since an inferred schema’s record names are implementation-derived and never expected to match byte-for-byte.

Both fixes were verified by mutation, twice: once against the (later corrected) global change, and again against the final three-mode design. A deliberate to_osd spacing change now fails 18 Track 2 vectors while leaving Track 1 at 19/0 (exactly as docs/conformance-harness.md says it should – Track 1’s "exact" is meant to tolerate this); deleting quote_label’s escaping entirely fails the 4 OSD-15 vectors directly. Neither mutation-and-revert cycle surfaced an actual writer bug – with the corrected comparisons in place, both tracks were unchanged at 233/0/40 (of 273, v0.21.0-beta) and 19/0.

Real bugs this harness found and fixed

Building this harness against the real spec (rather than trusting the Python/TypeScript ports as ground truth) found seven real product bugs, all fixed across this port’s 0.1.0-alpha/0.1.1-alpha releases:

  • XML reader was type-coercing leaf text (int/float/bool) at parse time, contradicting the spec – XML has no typed literals. Fixed: read_xml always produces string leaves now; see XML.
  • YAML’s implicit-int resolver was missing the legacy sexagesimal form – 12:00:00 stayed a string instead of resolving to 43200. Fixed; see YAML.
  • YAML mapping keys were never run through the implicit-type resolver (the “Norway problem”) – on: wasn’t rejected as YAML 1.1 requires. Fixed to match Python’s reference behavior exactly (any non-string key is rejected, not just bool/null-shaped ones); see YAML.
  • OML’s tokenizer wasn’t canonicalizing temporal literal text – missing seconds got dropped instead of filled to :00, and sub-second fractions weren’t zero-padded to 6 digits; see OML.
  • OML’s writer shape-guessed date/time/datetime from string content to decide bare-vs-quoted, since Scalar has no temporal variant (issue #16) and thus no real provenance signal – a plain JSON string that merely looked date-shaped got silently promoted to a genuine OML temporal literal on write. Found while directly verifying, not just trusting, this suite’s own reported numbers: the bug had a fully-green 117/0/22 run despite existing, because no vector at the time tested it. Fixed by tagging genuine provenance (OML’s own bare-literal grammar, or a schema-directed materialize upgrade) on RawNode instead of guessing from shape; see OML. omnist-spec’s own v0.1.1-alpha adds the 6 vectors (formats-oml/oml.json) this fix now passes for real.
  • One harness-side false fail: a JSON temporal-write-report vector is structurally unreachable given this port’s any-scoping decision (see limitations.md); reclassified from fail to a cited skip rather than a product fix.
  • A separate harness-side skip, since resolved by issue #105: the formats-json/basic/temporal-leaf-is-stringified-on-write vector was structurally unreachable because Scalar had no temporal variant to preserve through the harness’s own vector decoder (issue #16/#89) – skipped, cited, not a product bug. Issue #105 gave Scalar real Date/Time/Datetime variants, the decoder now preserves them, and this vector passes for real; its skip detector has been removed from vector_runner.rs.
  • Scalar::Int(i64) rejected valid arbitrary-precision integer literals – omnist-spec section 2.2 defines integer as arbitrary-precision (bounded only by the shared 4,300-digit cap), not fixed-width; a 20+ digit OML literal was rejected outright with no digit-cap override in play, a real grammar-acceptance bug (spec section 9.2), not a permitted narrower-limit variation. Not found by this harness on its own – surfaced by a maintainer-prompted ledger-legitimacy audit (“is this a genuine language limitation or an unexamined shortcut”) on the omnist-spec side, which added the vector this fix now passes for real. Fixed by moving Scalar::Int/Value::Int onto num_bigint::BigInt; see Limitations. Found and fixed along the way, not assumed mechanical: the YAML legacy sexagesimal literal’s fold used to rely on i64 overflow as an incidental size bound – a naive BigInt swap would have silently removed it, letting a many-:-group literal build an arbitrarily large integer with nothing stopping it. Fixed by enforcing the existing digit cap explicitly on the fold’s result instead.

None of these required filing against omnist-spec, Python, or TypeScript – every real fail traced back to an omnist-rs bug when checked against a live Python run first, per this project’s own cross-implementation triage rule.

Known non-blocking gap in the CI gate itself

cargo llvm-cov --workspace --fail-under-lines 100, this project’s coverage gate, has an open, unexplained discrepancy where its exit code doesn’t reliably correlate with its own printed Lines% column across commits – see omnist-rs#95. Not specific to the conformance work; noted here because it surfaced while landing it.

Limitations & stability

Alpha status: 0.12.0-alpha, per this project’s versioning rule

The Rust port’s first feature-complete milestone (issue #28) plus its own conformance-test harness against omnist-spec (issue #82 – see Conformance against omnist-spec for the real, measured results) are both now in place, and the maintainer has signed off on moving past 0.0.x to mark that milestone. It still ships -alpha, though: there is no beta until the maintainer explicitly signs off on the scoping decisions below (the any-type gap chief among them); accumulating further features or fixes alone never moves it past -alpha on its own. Treat every public API in this crate as subject to change without a deprecation cycle until that further sign-off happens.

Document paths quote a label that needs it (E-36)

Every Document path this crate prints (DocumentError.path, ValidationError.path, Adjustment.path) writes a label as .label, unless the label is empty or holds . [ ] " \, a control character, U+007F, U+0085, U+2028 or U+2029: then it is ["label"] with no dot, escaping " as \", \ as \\, \n, \r, \t, and any other of those as lowercase \u001f. The E-10 index follows either form ($["a.b"][1]). Schema paths (Record.label) keep the raw label.

Programmatic-schema rules from spec v0.28.0-beta (S-8, S-22, S-23, S-24)

These four rules take a schema built through the API, which no conformance vector can supply (DIV-5), so the unit tests in omnist/tests/spec_v028.rs are the only pin.

  • S-8 and S-24/OSD-16 are implemented. Schema::new reports a bad record or Ref target name as schema.invalid-name at $; osd::to_osd fails with write.unsupported-value at the record path on a max = 0 field. Which violation is reported when several exist is unspecified.
  • S-22 (schema.invalid-label) is vacuous in Rust. A field label is a String, so it cannot hold invalid UTF-8. Every public way to give a label takes impl Into<String> (Field::new, Field::required, …); there is no OsString, Vec<u8> or Cow<[u8]> route into a schema, so there is nothing to check and no code path emits schema.invalid-label. Input bytes are a different rule (D-14, parse.invalid-encoding).
  • S-23 (schema.unknown-record) has no Rust surface. No public function takes a caller-supplied ordering of records: to_osd and the other writers emit Schema::env() in its own order, and extract’s keep argument is a set of field labels, not records. schema.unknown-record is therefore never emitted.
  • No OSD-OML schema writer. The port has no OSD-OML schema reader or writer, so the OSD-OML half of OSD-16/S-24 does not apply.

The any-type support (landed)

Python’s schema model has an AnyType/ANY type and an allow_any option several APIs (osd, schema algebra, inference) use as a fallback when a precise type can’t otherwise be resolved. In this Rust port, FieldType::Any is fully supported across omnist::schema, omnist::osd parsing (record X { "a": any }), and omnist::infer (with allow_any fallback mode when schemas have ambiguous types or mixed structures, also wired into the CLI’s infer --allow-any flag).

Safety limits and the YAML alias expansion factor (D-10, D-11)

Every limit below is finite, documented here, and reported with its document.limit.* code (spec section 2.4):

LimitDefaultConfigurable
Maximum nesting depth200yes: Limits::max_depth (0 = default, at most 250)
Maximum node count1,000,000 (a node is a container, not a key or a scalar value)yes: Limits::max_nodes (0 = default, at most 10,000,000)
Maximum integer digits4,300yes: Limits::max_int_digits (0 = default, at most 43,000)
Maximum input size (bytes, D-23)64 MiB (67,108,864 bytes)yes: Limits::max_input_bytes (0 = default, at most 1 GiB); CLI --max-input-bytes N
Maximum alias expansion factor (YAML)50yes: YamlReadOptions::max_alias_expansion (0 = default, at most 10000)
Maximum expanded size (YAML, inputs with an alias or merge key)1,000,000 value slotsyes: YamlReadOptions::max_expanded_slots (0 = default, at most 10,000,000)

The first four are omnist::limits::Limits, taken by the explicit-limits readers read_oml_with, read_json_with, read_toml_with, read_xml_with, by YamlReadOptions::with_limits (for read_yaml_with) and by Doc::from_raw_with / Doc::of_with; the plain read_* functions use the defaults, unchanged. As for the YAML options, 0 selects the default (a zero or unset value never widens a limit) and a value above its ceiling is refused, not clamped, by Limits::validate (an uncoded DocumentError). The ceilings are this port’s own, because the spec recommends none for these three; the depth ceiling is what the recursive readers survive on a 2 MB thread stack in a debug build (measured: 300 levels pass, 400 overflow; a release build passes 1,000), and all three bound the work a caller can opt into. On a smaller stack, lower the depth. A limit equal to the document’s depth, node count or digit length accepts it and one past refuses it. A Doc remembers the limits it was built under, so a later add or set enforces the same ones. write_oml and the other writers, which take a bare RawNode or an existing Doc, still refuse nesting past the default depth of 200.

A limit crossed is a DocumentError (spec E-11), never a ParseError: document.limit.depth and document.limit.nodes carry path $ (they are properties of the whole input, as for document.limit.expanded-size), and document.limit.int-digits carries the Document path of the over-long integer ($.n, $.a.n, and an index only when the label repeats: $.n[1]). Through 0.6.1-alpha the OML scanner and the JSON, TOML and YAML readers raised these as a ParseError with a line:col position. read_oml, whose signature is unchanged and returns a ParseError, still reports them that way, with the position; read_oml_with and the registry’s oml format report the Document form. A syntax error anywhere in the input wins over an over-long integer.

Two readers have a ceiling of their own below ours: toml_edit refuses nesting past about 80 levels (inline tables, arrays, dotted keys and table headers alike) and holds every integer in 64 bits, so a larger max_depth or a max_int_digits above 19 does not make the TOML reader accept more than toml_edit does. Its recursion cap is reported as document.limit.depth at $, with a message naming toml_edit’s own cap (through 0.8.0-alpha a table header deeper than 80 levels panicked the reader, omnist-rs#197, and the other spellings were parse.codec-syntax). The XML reader counts an element as a node once it has a child element, so a million leaf elements are one node; its text is never an integer, so max_int_digits governs only the schema-guided pretyping of read_xml_with_schema_and_limits: an integer leaf over it is document.limit.int-digits at the leaf’s path (through 0.11.0-alpha it was materialize.inexact-conversion, omnist-rs#204). A number leaf is exempt however long its integer part (D-28) and reads as an infinity past binary64 (D-29); through 0.11.1-alpha a number leaf over the limit stayed a string (DIV-42).

The alias expansion factor bounds the materialized-to-written value-slot ratio of every anchored node, every other mapping and sequence, and the document root; see YAML for the counting rules and for what the default accepts (for example, merging a 150-key anchor into a mapping that writes one key of its own reads E ~ 50.67 and is rejected at the default). It is a ratio, so it does not replace the node cap: a scalar-heavy document is bounded as a whole only by max x S(root), and the YAML node cap stays in force. Only YAML has an anchor mechanism, so the factor binds no other codec.

The ratio does not bound absolute size, so the reader also enforces a maximum expanded size (D-22): an input that contains at least one alias or merge key and whose root materializes more than 1,000,000 value slots is refused with document.limit.expanded-size, after the ratio check (an input that fails both reports document.limit.alias-expansion). Ratio and size are separate options, and neither implies the other. An input with no alias and no merge key is exempt however large, which is a cliff by design: a plain file of two million slots passes, and adding one alias subjects it to the cap. See YAML. The YAML node cap counts what D-9 counts, containers only, and defaults to the spec’s 1,000,000 (it is Limits::max_nodes, set through YamlReadOptions::with_limits), so the spec’s own 1,000-service x 60-key compose example (W = 62,063) is accepted (it was refused with document.limit.nodes through 0.6.0-alpha, DIV-11, omnist-rs#189). A malformed merge (a scalar merge value, a scalar or sequence member of a merge sequence, an alias to a sequence of scalars) is parse.codec-syntax and wins over both limits.

Maximum input size (D-23, D-24)

The spec says an implementation SHOULD enforce a finite maximum input size in bytes (D-23), names no default (D-24), and asks the implementer to measure the slowest codec at the size chosen. This port enforces one, 64 MiB by default (the same number the Python port chose), configurable with Limits::max_input_bytes and, on the command line, --max-input-bytes N on format, convert, check, validate and infer.

  • Bytes, not characters: é is two, and a leading byte-order mark is three, because the length is taken before the mark is stripped (D-15) and before any decoding or parsing. An input of exactly the maximum is accepted, one byte more is refused with document.limit.input-size at $.
  • First: the check precedes every other diagnostic, so an oversized input that is also invalid UTF-8, malformed or doubly BOM-marked reports document.limit.input-size. It covers all five formats, an alias-free YAML input included, and Doc::from_format for a registered (plugin) format too. read_oml, which returns a ParseError, reports it at 1:1; every other reader, and read_oml_with, report the DocumentError form with path $.
  • Streaming: the CLI reads a file or stdin through a reader limited to the maximum plus one byte, so it refuses an oversized input after reading max + 1 bytes rather than buffering the rest. The refusal says how to raise the limit.
  • Not bounded: an OSD schema text (D-25 asks for a schema-size bound but gives it no code and no vector; the CLI’s schema files and --schema are read whole), and a Document built from native values (it has no input bytes).

Why 64 MiB, and what it costs. The cap bounds parse cost; it does not make a parse fast. Measured on 2026-10-05 (release build, one run each, a flat mapping of integer entries filling 64 MiB; TOML and YAML also measured at 8 and 16 MiB and scale about linearly): OML 2.4 s and 0.5 GB peak, XML 7.1 s and 0.6 GB, JSON 6.1 s and 1.0 GB, TOML 15.8 s and 2.5 GB, YAML 18.8 s and 2.7 GB. The spec gives no default (D-24) and asks an implementation to document its value and to measure its slowest codec on a worst-case input before raising it; these measurements are why the default is documented here. A deployment that reads untrusted TOML or YAML on a small host should set a lower maximum (the same inputs at 10 MiB: TOML 2.1 s and 0.43 GB, YAML 2.7 s and 0.46 GB, the others under 1.3 s and 0.17 GB). Before 0.9.0-alpha a reader took any size, so a caller that reads more than 64 MiB now has to raise the limit explicitly: that is a behaviour change, not only a new option.

C-9: writers refuse a string with no UTF-8 encoding (vacuous here)

Spec C-9 (v0.32.0-beta) says every writer MUST fail with write.unsupported-value when a Document holds a string value or an edge label with no UTF-8 encoding. In this port the rule cannot fire. A Rust String, &str and char always encode, so no Document, RawNode or Value can hold such a string, and every API that takes text takes &str or String. The audit of 2026-10-05 found:

  • No library entry point takes bytes, an OsString or a Cow<[u8]>. The only byte entry points are the CLI crate’s read_document_bytes(_with), read_oml_bytes(_with) and parse_schema_bytes, which decode with String::from_utf8 and refuse invalid input with parse.invalid-encoding (D-14), never repairing it.
  • No lossy conversion on input. No production code calls String::from_utf8_lossy on bytes that could be invalid, to_string_lossy, or an _unchecked constructor, except two from_utf8_lossy calls in the XML reader, which work on slices of an already valid &str cut at ASCII delimiters (a CDATA body, an attribute value) and so never see an invalid byte. Every reader refuses a lone surrogate escape (JSON \ud800, TOML, OML, YAML, XML &#xD800;) as a syntax error and never turns one into U+FFFD, which is the Go-style bug.
  • A tripwire test (omnist/tests/c9_vacuous.rs) fails if a new lossy conversion appears in production code, so a future route is audited, not silent.

No vector pins C-9 (an input is UTF-8 text or bytes_hex, neither of which can carry a surrogate), so the ledger’s row for it is “vacuous” for this port.

Scalar::Int is arbitrary-precision (issue #104)

omnist::document::Scalar::Int and Value::Int are backed by num_bigint::BigInt, not a fixed-width integer – matching omnist-spec section 2.2’s requirement that integer be arbitrary-precision, and Python’s/Go’s own representations (int, *big.Int). This was previously i64 (max ~19 significant decimal digits) – a real spec-conformance bug, not a disclosed permitted variation, since a 20+ digit literal under the shared 4,300-digit security cap was rejected outright with no declared_max_int_digits override in play (omnist-spec ledger entry D-9). Fixed; see each format’s own page for anything still worth knowing:

  • formats/toml.md – TOML integers read at full precision too: toml_edit, the crate this port’s TOML codec is built on, holds an integer in 64 bits and refuses a longer literal, so the reader works the literal out itself (any radix) and puts the value back. The digit limit counts the decimal digits of the value (spec D-28). No format has a 64-bit ceiling on read or on write.

TOML integers past 64 bits: cost of reading them

toml_edit refuses an integer past 64 bits, so the reader finds those literals itself (see TOML). Doing that one literal per reparse was quadratic: in a release build, an array of N copies of a 20-digit integer took 10.6 s at N = 4,000 (84 KB), 38 s at 8,000, 147 s at 16,000 and 324 s at 32,000, where the previous release refused the first such literal at once. A single scan now finds every such literal and the text is parsed once more, so the cost is linear: 20,000 big integers in an array, 20,000 inline tables each holding one, and 20,000 1e999 floats read together in 0.41 s (release; the test many_big_integers_read_in_linear_time bounds it at 60 s in a debug build). Before the fix the same three shapes at 6,000 each took 282 s.

Temporal kinds have no arithmetic

Scalar/Value carry real Date(String)/Time(String)/Datetime(String) variants (added in issue #105), each holding an already shape-validated, canonical ISO spelling – but the string is opaque data, not a chrono/ time value. There is no date arithmetic, comparison, or component extraction anywhere in this crate; the algebra never needed it (mirroring the same no-arithmetic reasoning Scalar::Int’s BigInt backing already applies to integers). This means:

  • omnist::infer::infer infers date/time/datetime only from a genuinely temporal-kinded sample (one already read as Scalar::Date/ Time/Datetime – e.g. from OML’s or TOML’s own native temporal grammar); a plain ISO-shaped string sample still infers as string, matching Python’s own strict value_kind() exactly.
  • omnist::schema::matches_kind, by contrast, accepts either a real temporal variant or a shape-matching plain string for a Date/Time/Datetime-typed field – also matching Python’s own hybrid matches_kind exactly. A schema-directed materialize upgrade is what promotes a matching string to the real typed variant.
  • Formats with a native temporal type on the wire (TOML’s four temporal literal forms, YAML’s looser timestamp grammar) now construct the real typed variant directly on read and write it back bare on write – no more silent collapse to Scalar::Str; see each format’s own page for the exact behavior (particularly formats/toml.md, whose write-side shape-guessing divergence from Python is now resolved).

Architecture-freedom disclosures already made per codec

Beyond the two structural gaps above, each format module documents its own disclosed, live-checked divergences from the Python reference (namespace resolution in XML, ASCII-only digit parsing in XML’s coercion, strict vs. non-strict OML-Extended string spellings, and more) – see formats/ for the specifics, all checked against a live Python interpreter or the Python reference’s own merged PR history, not assumed from memory.