The ChakataStat dataset format (.ckd)
.ckd is ChakataStat's native document format — what File → Open and File →
Save read and write. This document specifies version 2 of the format and
records the engineering decisions behind it.
Reference implementation: lib/io/document_codec.dart,
exercised by test/document_format_test.dart.
At a glance
A .ckd file is a ZIP archive — the same container approach as .xlsx
and .ods — with two members:
example.ckd (ZIP)
├── meta.json — variables + provenance (UTF-8 JSON)
└── data.parquet — the dataset's columns, typed and columnar (Apache Parquet)
meta.jsonholds everything ChakataStat knows about the dataset except the cell values: each variable's full metadata, plus document provenance (format/version, timestamps, the writing app and engine). It is human-readable — unzip the file and open it.data.parquetholds the cell values as typed, columnar Parquet, written and read by the native engine (over the Apache Arrow / Parquet crates). It is omitted when the dataset has no variables (an empty document is justmeta.json).
The file holds data only. Session and application state — the Select / Weight / Split case state, the output log, window layout, the AI conversation — is deliberately not in the document; it is owned by the session store. The document owns what belongs to the dataset; the session owns the rest.
meta.json
{
"format": "ChakataStat-dataset",
"version": 2,
"created": "2026-01-02T03:04:05.000Z",
"modified": "2026-06-14T09:00:00.000Z",
"writer": { "app": "0.5.0+6", "engine": "ChakataStat engine 0.5.0 (native)" },
"rows": 3,
"variables": [
{
"name": "Age",
"type": "numeric",
"width": 5,
"decimals": 0,
"label": "Age in years",
"valueLabels": { "99": "Unknown" },
"missingValues": ["99"],
"alignment": "right",
"measure": "scale"
}
]
}
| Field | Meaning |
|---|---|
format |
Always "ChakataStat-dataset"; identifies the file. |
version |
Format version. This build reads 2 only (see Versioning). |
created |
ISO-8601 UTC; when the document was first saved. Preserved across later saves. Optional. |
modified |
ISO-8601 UTC; when the document was last saved. Optional. |
writer.app |
ChakataStat version that wrote the file, taken verbatim from pubspec.yaml's version (single-sourced via lib/version.g.dart, generated at build time). Optional, provenance only. |
writer.engine |
Analysis engine that wrote it (e.g. "ChakataStat engine 0.5.0 (native)"; files saved by 0.4.x-era builds carry the engine string of that era, e.g. "Polars 0.46 (native)"). Optional, provenance only. |
rows |
Used row (case) count. Informational; the authoritative shape is the Parquet part. |
variables |
One object per column, in column order, each the serialization of a Variable (Variable.toJson): name, type (numeric/string/date), width, decimals, label, valueLabels, missingValues, alignment, measure. |
The variables array is the source of truth for the variable list. The Parquet
part's column names are ignored on read — display names come from
meta.json — which is how duplicate variable names survive (the data part
deduplicates them only to satisfy Parquet's unique-name requirement).
data.parquet
One Parquet column per variable, in the same order as meta.json's
variables, with the engine's two storage types:
- a numeric variable → an
f64column; - a string or date variable → a UTF-8 string column.
The data is stored faithfully — and this is the key design point:
A value a variable declares user-missing (e.g.
99flagged as a missing code) is a real value, so the file stores it as99, not as null. The missing-value definition lives inmeta.jsonand is reapplied when the file is opened, so analyses still exclude it. Missing-ness is metadata, not a hole in the data.
This is why saving does not reuse the engine's analysis frame (which nulls
user-missing values): a dedicated, frame-independent write path
(ig_write_parquet_staged) persists a faithful, typed view of the sheet. It is
the mirror image of ig_read_parquet, which also leaves the active frame
untouched.
What is and isn't preserved
| Case | Behaviour |
|---|---|
| Numeric value | Round-trips as a number; integral values print without a trailing .0 (34, not 34.0). |
Numeric formatting (e.g. 3.10, 007) |
Normalised by typed storage — 3.10 → 3.1, 007 → 7. Keep such values in a string column to preserve exact text. |
| Declared user-missing value | Preserved (real value kept; definition restored from meta.json). |
| Off-type text in a numeric column (a stray word) | Dropped to blank. It cannot be represented as f64; this is the one inherent loss of typed columnar storage. Same mapping the engine already applies. |
| String / date value | Round-trips exactly, including numeric-looking text. |
| Blank cell | Stays blank (sparse — only non-empty cells are stored). |
Versioning
The format is versioned and the reader validates it. This build reads version 2 only.
- Version 1 was a single pretty-printed JSON object (
{ "format": …, "version": 1, "variables": […], "cells": [[row, col, value], …] }). It is not read by this build: the format was reworked while the project is still pre-1.0, so backward compatibility was traded for a clean design. A v1 file is detected (it begins with{) and rejected with a clear message rather than a cryptic ZIP error. - A future version 3 would add a migration path here: the reader dispatches
on
version, so an upgrade reads the old shape and produces the new model.
Compatibility commitment
This is the promise users can rely on for their saved work — what production_readness.md gate 3 (file-format stability) asks for. It applies from 1.0 onward; pre-1.0 is explicitly exempt (which is why version 1 was dropped, above).
For .ckd documents:
- Forward-readable. Every 1.x release reads every
.ckdversion ≥ 2 — the version this build writes. A file you save today keeps opening in later 1.x releases. - Additive evolution. A new format version is introduced only when the model
genuinely grows, and the reader gains a migration path for it (it
dispatches on
versionand upgrades the old shape). A newer version is never made to silently fail on an older file: an unreadable file is rejected with a clear message, never a cryptic error or silent data loss. - Retiring a version is a major event. An old version's read support is removed only at a major release (e.g. 2.0), only after it has been readable across the whole prior major line, and the release notes say so. The pre-1.0 → v2 break will not recur within 1.x.
- Faithful round-trip. Within these rules the documented round-trip guarantees (the What is and isn't preserved table) hold across versions.
For the session / recovery file (session_state.dart): best-effort and forward-tolerant, not a hard guarantee. It holds recoverable application state, not user documents, so an unreadable or newer-than-expected session falls back to a clean start and never blocks launch. Older sessions load with newer sections defaulted (the reader is deliberately lenient).
How this is enforced. A committed golden file —
test/fixtures/golden_v2.ckd, a real v2 file
written by an earlier build — is opened and checked on every run by
test/format_compat_test.dart, alongside an
old-version session fixture. A change that breaks reading a prior file fails that
test. (The in-build round-trip tests in
test/document_format_test.dart prove
self-consistency; the golden file proves cross-build compatibility.) Regenerate
the fixture only on a deliberate format change:
flutter test --dart-define=GENERATE_FIXTURES=true test/format_compat_test.dart
Determinism
The format avoids gratuitous nondeterminism so a content-identical re-save is as reproducible as the toolchain allows:
- ZIP entries use a fixed modification time (the real timestamps live in
meta.json), so the archive structure does not churn. createdis stable across saves; onlymodifiedadvances.
meta.json is fully deterministic for identical content. The Parquet part's
exact bytes are the engine's (the Parquet writer's) to define and are not guaranteed
byte-stable — a property intentionally not relied upon (the app tracks unsaved
changes in memory, not by hashing files).
Why these choices
- ZIP container, not single-file. A data-analysis document wants typed,
columnar, compressed storage that loads straight into the engine. Parquet is
exactly that, and the engine already reads and writes it. Variable metadata
doesn't fit in Parquet columns; rather than depend on Parquet footer
key-value metadata (which the engine's Parquet write path deliberately does
not use) and the
fragile low-level code needed to write it, the metadata rides in a sibling
meta.json. The container reuses stable, tested engine endpoints with no changes to the Parquet writer — the robust choice for a format we want to get right once. - Opening requires the native engine. Reading the Parquet part goes through the engine (FFI). That is acceptable: the engine is already a hard requirement for the app to run at all.
- Data only; state lives elsewhere. Keeping the document to data (and data-about-the-data: timestamps, writer) keeps a clean boundary with the session store, which already owns application and session state.
Engine endpoints
| Endpoint | Role |
|---|---|
ig_write_parquet_staged(path) |
Writes the staged columns to a Parquet file without touching the stored frame — the faithful save path used by Save. Stage columns with ig_dataset_load_begin + ig_stage_* first. |
ig_read_parquet(path) |
Reads a Parquet file into columnar form without touching the stored frame — used by Open and Merge. |
ig_write_parquet(path) |
Writes the stored frame (the engine's analysis view, user-missing nulled). Used by File → Export to Parquet, not by the .ckd document path. |