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.2.2-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)));
// 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 – see the workflow playbook’s “architecture freedom” note – 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;
}

Depth and total-node-count guards enforced on every construction path.

#![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>) -> String;
}

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.

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;
}

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.

#![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,
    pub message: String,
}

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

pub struct ParseError {
    pub line: usize,
    pub col: usize,
    pub message: String,
}

pub struct FormatError(pub String);

pub struct WriteError {
    pub message: String,
    pub report: Option<crate::report::WriteReport>,
}

impl WriteError {
    pub fn new(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 – OML source text could not be parsed; carries a “line N, col N: msg” position.
  • 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.

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.

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 its own materialize.* namespace, e.g. "materialize.inexact-conversion").

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.

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).

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

A JSON integer literal over 4300 digits is a ParseError (mirrors CPython’s sys.set_int_max_str_digits guard, which fires inside json.loads before Python ever sees the value). 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 (an unquoted << key’s value must be a mapping or sequence of mappings, merged in order, explicit keys taking precedence).

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.

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.

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 at path "$" 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, 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 null-valued field drops the field entirely ({"a": 1, "b": null} writes as a = 1\n, no trace of b); a null inside an array drops just that element, shifting later elements down. Each drop is recorded as a null.omitted/Severity::Warning adjustment, live-confirmed against tomli_w.dumps (Python’s reference TOML writer) to match exactly, path-for-path. strict mode raises even though the severity is only Warning.

Integer digit cap, and a real, external i64 ceiling from toml_edit

Live-confirmed against tomllib.loads: a decimal integer literal follows the identical 4300-digit sys.set_int_max_str_digits cap json.rs/yaml.rs already apply. Hex/octal/binary literals are a genuine exception in Pythontomllib.loads("x = 0x" + "f" * 10000) parses successfully with no error at all, because CPython’s digit-limit guard explicitly exempts power-of-two bases.

This port does not replicate that decimal/non-decimal split, but for a different reason than it used to (issue #104: Scalar::Int/Value::Int are arbitrary-precision BigInts now, not i64). The underlying toml_edit crate’s own Integer type is i64-backed regardless of radix (the TOML 1.0 format spec itself documents 64-bit signed integers), so reading a >19-digit literal of any radix from TOML source text fails in toml_edit’s own parser, before this codec’s Scalar conversion ever runs – a real, external, still-current divergence from Python’s arbitrary-precision int, not something this port’s own representation choice can lift. Live-confirmed: convert --from toml on n = 99999999999999999999999999 fails with "integer literal ... is out of range for a 64-bit integer". Writing an oversized Scalar::Int to TOML, by contrast, succeeds – this codec’s writer renders integers as plain digit text rather than going through toml_edit’s typed API, so the ceiling is read-side only; live-confirmed the same 26-digit value round-trips out via convert --from oml --to toml with no error, it just can’t be read back in through TOML afterward.

Native temporal types, truncated (not rounded) sub-microsecond precision

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). Fractional seconds beyond microsecond precision are truncated, not rounded to six digits, live-confirmed against tomllib: 00:32:00.9999999 (7 nines) reads as datetime.time(0, 32, 0, 999999), matching this module’s integer- truncating conversion exactly. 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 quoted.

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; an undefined entity is a parse error) – XXE -safe by construction, not by configuration (Python’s read_xml needs defusedxml instead of the stdlib ElementTree for the same protection).

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 value.stringified adjustment).

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).

#![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. 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.

Integer digit cap and i64

Same 4300-digit cap (MAX_INT_DIGITS) as json.rs/yaml.rs/toml.rs, and the same i64-backed representational ceiling – see formats/json.md.

--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.

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 (corrected finding, PR #21)

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 same i64 bound to every radix uniformly, via toml_edit’s parser. Any oversized hex/octal/binary literal is rejected the same way an oversized decimal one is.

An earlier draft of PR #21 (and its module doc) incorrectly claimed hex literals hit the identical ValueError as decimal ones in Python – that claim was wrong and was corrected mid-review once live-checked against tomllib directly. The corrected finding: Python’s radix exemption is real, and this port does not replicate it, because Scalar::Int is i64-backed regardless of the literal’s source radix – there’s no representational path for an “uncapped hex” literal past 64 bits in this port regardless of what the cap logic does.

Permanent: yes, for the same reason as the general i64 gap above – revisiting it would require a non-i64 Scalar::Int.

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 f93c569, past the v0.2.2-alpha tag – pinned to the exact commit adding issue #104’s conformance vector, ahead of any tag that includes it yet) 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): 129 passed, 0 failed, 23 skipped (of 152 vectors).

Zero real fails on either track as of this writing. 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

Every skip below is cited in tools/conformance/src/bin/vector_runner.rs’s own source (either “not yet implemented” or a numbered entry in the spec’s divergence ledger section 9.4), per section 8.5.5’s requirement that no skip go unexplained. The two structural categories:

  • 6 limits.json vectors: each expects a vector-local configurable limit (a specific max-nodes/max-depth/max-int-digits value scoped to that one test case). This port’s MAX_NODES/MAX_DEPTH are general, crate-wide constants (omnist::document), not something the harness can override per-vector – there is no representational path to make these pass without adding a runtime-configurable limits API this port doesn’t otherwise need. Distinct from the divergence ledger’s D-1 entry in the specific reason (a vector-local knob, not a fixed-ceiling-value mismatch), even though both are filed under the same general “limits” heading.
  • ~16 remaining skips: OSD-grammar and OML-grammar/format-specific vectors exercising syntax this port’s parsers don’t yet accept, each cited individually in-source with the specific grammar gap.

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, 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 in code-agnostic mode (path-set only, not exact error-code string). This port’s ErrorCode::as_str() now does produce the spec’s family-namespaced codes ("validate.type-mismatch", "materialize.inexact-conversion", per §8.3.1, fixed in issue #152) – but the vectors and fixtures are still compared code-agnostically regardless, since some still carry the pre-namespacing bare form recorded against the reference implementation (omnist-spec D-4, open). Same mismatch found independently on the TypeScript port; not Rust-specific.

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 form12: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.2.2-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.

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).

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.mdone real, external divergence remains: toml_edit, the crate this port’s TOML codec is built on, has its own i64-backed integer type (the TOML 1.0 format spec itself documents 64-bit signed integers), so a >19-digit integer literal in TOML source text is still rejected – by toml_edit’s own parser, before this port’s Scalar is ever involved. Writing an arbitrary-precision Scalar::Int to TOML still succeeds (this codec’s writer renders integers as plain digit text, not through toml_edit’s typed API), so the asymmetry is read-side only: such a value round-trips out but not back in through TOML specifically. Every other format (JSON, YAML, OML) has no such ceiling.

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.