Native Persistence Layer — architecture impact & merit report

Scope. Evaluates a proposed new project: a native (Rust), fast, light-dependency library sitting “between nirs4all-io and dag-ml-data” that owns all persistence of predictions and scores (save/load prediction tables + metric/score records) so this persistence is available cross-language (Python/R/MATLAB/WASM) without heavy Python deps.

Method. Grounded in the actual code of all four candidate layers (cited file:line). No speculation. Aligns to the Migration Mandate (dag-ml/AGENTS.md:12-26) and the war-room target split (TARGET_RESPONSIBILITY_SPLIT.md).

Assembled 2026-06-23.


Executive summary (read this first)

  1. dag-ml already persists prediction tables natively in Rust, light-dep. FilePredictionCacheStore (dag-ml-core/src/runtime.rs:1113) writes per-fold + OOF + aggregated (avg/w_avg) prediction blocks to a JSON directory store with sha256 fingerprint validation, plus an export-prediction-cache-store / validate-prediction-cache-store CLI pair (dag-ml-cli/src/main.rs:1836-1862). Its only deps are serde_json + sha2. The “predictions” half of the proposal is ~80% already built.

  2. The real gap is SCORES, not predictions. dag-ml computes full per-fold/per-target/multi-metric reports (RegressionMetricReport, metrics.rs:100) but never persists them — they collapse to a winning scalar + rank list inside the bundle (SelectionDecision, selection.rs:254). There is no queryable metrics table. That, plus all-partition (test/holdout/final) persistence and a columnar/queryable surface, is the genuine missing capability.

  3. The proposed placement is wrong. The nirs4all-io dag-ml-data seam is the input path (io emits a CoordinatorDataPlanEnvelope — schema + plan + identity, no outcomes). Predictions/scores are outputs and never flow through it. io explicitly disclaims storage (nirs4all-io/CLAUDE.md:156-158); dag-ml-data explicitly disclaims “OOF prediction blocks” and model outputs (dag-ml-data/docs/ARCHITECTURE.md:53-58). Output persistence belongs on the dag-ml side, downstream of the model run.

  4. A separate repo is not justified. The capability is generic ML-coordination output — exactly dag-ml’s charter (AGENTS.md:17 lists “prediction/score persistence” as a dag-ml mandate). A new repo would duplicate bundle.rs/runtime.rs/provenance.rs, add a release-train hop (ADR-10), fork the fingerprint/schema contract, and split a single replay story across two codebases. Recommendation: extend dag-ml’s existing prediction-cache into a queryable predictions+scores store, in a dag-ml-core submodule (or a thin dag-ml-store crate inside the dag-ml workspace). No new project.

  5. Back-compat is a one-way bridge, not a shared format. The frozen 0.9.x nirs4all workspace (SQLite + Parquet + joblib) and .n4a (nirs4all/CLAUDE.md:5) must stay as-is. The native store does not read/write that schema; nirs4all keeps a thin Python writer that mirrors native records into the legacy workspace during the migration window (ADR-02 bundle-readability SLA). The joblib/pickle artifact format is the hard cross-language wall and is out of scope for a predictions/scores store regardless.


1. Current-state map — who persists predictions/scores today

Two independent, overlapping persistence systems exist.

1a. nirs4all (Python, the production system) — nirs4all/pipeline/storage/

A hybrid SQLite (metadata + scalar scores) + Parquet (dense arrays) + content-addressed joblib (fitted artifacts) workspace, schema SCHEMA_VERSION = 2 (store_schema.py:28).

What

Where

Format

Evidence

Runs / pipelines / chains / artifacts / logs / projects

SQLite tables

rows + JSON-in-TEXT

store_schema.py:44-186 (7 tables)

Prediction rows (per-fold fold_0.., ensemble avg, refit final) + scalar scores

predictions table

SQLite, upsert on natural key

store_schema.py:111-148; fold/refit semantics workspace_store.py:1216-1238

CV score summaries (cv_val_score, cv_scores JSON), refit + aggregated-ensemble scores

chains table

SQLite columns

store_schema.py:77-109

Dense arrays: y_true, y_pred, y_proba(+shape), sample_indices, weights, per-sample metadata (JSON)

arrays/<dataset>.parquet

Parquet (pyarrow write / polars read), Zstd-3

array_store.py:124-140, :292-297, :397-399

Fitted models

artifacts/ab/<sha256>.joblib

joblib / pickle, content-addressed, ref-counted

workspace_store.py:190-210, :1353-1379

Portable export

exports/*.n4a (ZIP)

JSON manifest/trace/chain + joblib artifacts

workspace_store.py:2271-2383; richer bundle/generator.py:305-378

  • WAL mode is enabled (store_schema.py:606-607), with an RLock + retry-on-lock decorator (workspace_store.py:116-157) and POSIX/Windows file locking on the Parquet dir (array_store.py:202-243).

  • Deps (persistence-relevant): sqlite3 (stdlib), pyarrow>=14 (Parquet write — heavy), polars>=1.0 (Parquet read + all DataFrame returns — heavy Rust binary), numpy>=2, joblib>=1.2 (Python-only), pandas (convenience only), pyyaml. The Predictions facade (data/predictions.py:183) buffers rows during a run and flushes via WorkspaceStore.save_prediction (row-by-row SQLite) + array_store.save_batch (one Parquet write per dataset) inside a transaction (predictions.py:927-1044). Store is injected via register_store_backend (predictions.py:496-514) — data layer never imports pipeline layer.

1b. dag-ml (Rust, the migration target) — dag-ml-core/

A JSON-only, fingerprint-validated, replay-grade persistence layer. All persisted types derive serde.

What

Type (file:line)

Persisted?

Per-fold / OOF sample-level predictions

PredictionBlock (oof.rs:29-40)

✅ in cache store

Aggregated avg / w_avg / median predictions

AggregatedPredictionBlock (aggregation.rs:67-78); methods policy.rs:91-100

✅ in cache store

y_true target block

RegressionTargetBlock (metrics.rs:39-46)

serde-able

Prediction-cache payload (values) + manifest (metadata)

BundlePredictionCachePayloadSet (bundle.rs:753), BundlePredictionCacheRecord (bundle.rs:319)

✅ JSON files

Top-level run manifest

ExecutionBundle (bundle.rs:914-938) — schema-versioned (bundle.rs:121, SchemaMigrationPolicy bundle.rs:41)

execution_bundle.json

Selection outcome (winning candidate + ranks)

SelectionDecision / RankedCandidate (selection.rs:254, :248)

✅ in bundle

Full per-fold/per-target/multi-metric scores

RegressionMetricReport (metrics.rs:100-113)

computed, never persisted

Lineage / provenance

ResearchProvenancePackage (provenance.rs:35), W3C PROV-JSONLD + RO-Crate + OpenLineage (provenance.rs:17-26, :294, :396+)

✅ JSON files

  • The prediction-cache store is FilePredictionCacheStore (runtime.rs:1113): a directory of prediction-cache-<hex>.json payloads + a prediction_cache_manifest.json (runtime.rs:939, :1024-1031), written by write_payload_set (runtime.rs:1121) and re-validated against the bundle (fingerprint match) by validate_file_prediction_cache_store. CLI: export-prediction-cache-store (main.rs:1836-1853), validate-prediction-cache-store (main.rs:1854-1862). It is consumed via the RuntimePredictionCacheStore trait (runtime.rs:925) with File / Columnar / InMemory impls.

  • Deps: dag-ml-core = serde, serde_json, yaml_serde, indexmap, sha2, thiserror, tracing. No arrow, no parquet, no sqlite, no DB. The columnar f64 representation (ColumnarPredictionCacheStore, runtime.rs:1724; layout: "column_major_f64", capi/src/lib.rs:3537) is an in-memory ABI hand-off only — never serialized to disk.

1c. Quantified overlap (nirs4all SQLite+Parquet ⟷ dag-ml bundle/cache)

Capability

nirs4all (Python)

dag-ml (Rust)

Overlap

Per-fold predictions persisted

✅ Parquet+SQLite

PredictionBlock JSON

Full

OOF predictions persisted

✅ (query over rows)

✅ cache store (Validation-only)

Full

Ensemble avg / w_avg persisted

avg rows + final_agg_*

AggregatedPredictionBlock

Full

Final refit predictions persisted

final rows

⚠️ representable, but cache validate() forces partition==Validation (bundle.rs:353)

Partial

Test/holdout predictions persisted

partition column

⚠️ block type exists; cache store rejects non-validation

Partial

Full metric/score records (per fold/target/metric)

scores JSON + cv_scores

❌ collapsed to scalar+rank

None — gap

Manifest / run metadata

runs/pipelines/chains

ExecutionBundle

Conceptual

Lineage / provenance / PROV / RO-Crate

❌ (trace.json only)

✅ full

dag-ml only

Fitted-model artifacts

✅ joblib (Python-only)

handles/refs only (RefitArtifactRecord bundle.rs:846)

Different concern

Queryable analytical surface (“RMSE for target X across folds”)

✅ SQL / polars

❌ load-whole-payload-by-key

None — gap

Net: prediction-table persistence is substantially duplicated already. The non-overlapping, genuinely-missing pieces are: (a) a persisted metric/score table, (b) all-partition (not OOF-only) persistence, (c) a columnar-on-disk + queryable surface.


2. The gap — what actually blocks cross-language persistence

The cross-language problem is not “predictions can’t be persisted natively” — dag-ml already does that with serde_json + sha2. The real blockers are narrower and concrete:

  1. The production persistence is Python-only by construction. Every read/write in nirs4all goes through sqlite3 (stdlib API), pyarrow (Parquet write), polars (Parquet read, and every query_* returns a polars.DataFramestore_protocol.py:20, workspace_store.py:394). The file formats (SQLite, Parquet, JSON) are language-neutral, but the access layer must be reimplemented per language. (Evidence: §1a deps; predictions.py:927-1044 flush path.)

  2. Fitted artifacts are joblib/pickle (workspace_store.py:190-210) — fundamentally Python-object serialization. R/MATLAB/WASM can read the bytes but cannot deserialize the models. This is the true cross-language wall, and it is orthogonal to a predictions/scores store — predictions and scores are plain numbers; models are the unportable part. A predictions/scores persistence layer does not solve, and need not touch, the artifact problem.

  3. dag-ml persists for replay/lineage, not for analytical query. The cache store is keyed by requirement_key; you load a whole payload, you cannot query across folds/targets (runtime.rs:1113-1230). And the scores never get persisted at all (RegressionMetricReportCandidateScore → discarded except winner; metrics.rs:154, selection.rs:248). So even on the native side, there is no queryable predictions+scores artifact.

Conclusion of the gap analysis: the bottleneck is not “no native persistence.” It is (i) the production path is Python-coupled, and (ii) the native path persists predictions-for-replay but not scores, not all partitions, and not in a queryable form. A focused effort closes (ii) and lets every binding inherit it via the ABI; (i) dissolves automatically once the native store is the source of truth.


3. Proposed-layer design (evaluated against the existing code)

3a. Scope — what it should own

Candidate scope

Verdict

Why

Save/load prediction tables (per-fold/OOF/agg/final/test)

In (mostly exists)

PredictionBlock / AggregatedPredictionBlock already serialize; needs the all-partition relaxation (lift bundle.rs:353 Validation-only constraint for a results store)

Save/load metric/score records (per fold/target/metric)

In (the real new work)

RegressionMetricReport (metrics.rs:100) is serde-ready but unpersisted; add a MetricRecord store

Queryable/indexed read surface

In

the missing analytical capability

Lineage / provenance

Out — already dag-ml’s provenance.rs

don’t fork it

Fitted-model artifacts

Out

joblib/pickle/ONNX is a separate, harder problem; predictions/scores are numbers

Dataset/feature persistence

Out

dag-ml-data owns .n4d feature buffers (buffer_file_store.rs:1-9); not this layer

Scope should be predictions + scores ONLY, exactly as the proposal states. That is correct and matches the gap.

3b. Placement — three options, ranked

Option

Fit to charters

Cost

Verdict

A. Extend dag-ml’s prediction-cache into a predictions+scores store (submodule in dag-ml-core, or a thin dag-ml-store crate inside the dag-ml workspace)

AGENTS.md:17 lists “prediction/score persistence” as a dag-ml mandate; reuses bundle.rs/runtime.rs/fingerprints; one replay story

Lowest — incremental

RECOMMENDED

B. New crate inside dag-ml-data

❌ Violates charter: dag-ml-data “must not expose OOF prediction blocks” (docs/ARCHITECTURE.md:53-58), “must not own … model execution” (CLAUDE.md:11); predictions/scores are outputs

Medium + boundary breach

Reject

C. Standalone new repo “between nirs4all-io and dag-ml-data”

❌ Wrong seam (io→dag-ml-data is the input path; predictions/scores never traverse it — nirs4all-io/CLAUDE.md:156-158, io emits only a CoordinatorDataPlanEnvelope); duplicates dag-ml persistence; +1 release-train hop (ADR-10); forks the fingerprint/schema contract

Highest — new CI, license, repo, cross-repo contract sync

Reject

The proposal’s stated placement (“between nirs4all-io and dag-ml-data”) is architecturally mis-located: that seam carries the input CoordinatorDataPlanEnvelope (schema + plan + sample/target/group/origin identity, no outcomes — confirmed: the envelope schema has no prediction/score/metric field). Output persistence sits downstream of the model run, on the dag-ml side.

3c. Storage tech for “fast + light deps”

dag-ml’s hard constraint is no host runtime deps in the core (dag-ml/CLAUDE.md crate-direction note: dag-ml-core = “no host runtime deps”). Compare:

Tech

Deps added

Cross-lang read

Query

Fit to dag-ml

serde_json (today’s cache store)

none (already in)

trivial

none

✅ current baseline

Arrow IPC

arrow-array/arrow-ipc/arrow-schema (~1 family)

excellent (R/MATLAB/WASM/Python all read Arrow)

columnar scan

✅ already precedented & isolated in dag-ml-data-arrow behind a non-default feature (dag-ml-data-arrow/Cargo.toml:13-16)

Parquet-rs

parquet + arrow

excellent + compression

predicate pushdown

⚠️ heavier; matches nirs4all’s Parquet so easy bridge

redb / sqlite-rs

embedded DB

sqlite=universal; redb=Rust-only

indexed

⚠️ adds a DB dep to a JSON-pure core

custom columnar

none

must re-implement readers everywhere

custom

❌ reinvents Arrow

Recommendation: keep serde_json as the canonical/replay format (zero new deps, already validated), and add Arrow IPC as the columnar/queryable representation — gated behind an optional feature, mirroring the existing dag-ml-data-arrow pattern and the in-memory ColumnarPredictionCacheStore (runtime.rs:1724) that already produces column-major f64. Arrow gives R/MATLAB/WASM native read with one isolated dependency family and aligns with nirs4all’s Parquet (Parquet = Arrow-on-disk), easing the legacy bridge. Avoid adding an embedded DB to dag-ml-core.

3d. Cross-language binding surface

Reuse the proven ecosystem pattern (one Rust core + C ABI + thin per-language wrappers) already shipped by nirs4all-io (Python/R/MATLAB/WASM/C — nirs4all-io/COMPAT.md) and dag-ml-capi. The store surface crosses the ABI as: canonical JSON descriptors + dense f64 tensors (released via dagml_f64_columnar_tensor_free, capi/src/lib.rs) — predictions/y_true/scores as data, never as host handles (matches AGENTS.md:32-36). No new binding mechanism is needed.

3e. Relationship to dag-ml’s existing bundle/prediction-cache

Layer on top / extend — do not merge-away or supersede. The ExecutionBundle + FilePredictionCacheStore are the replay manifest + OOF cache and must stay (replay/lineage depend on them). The new capability is additive:

  1. Relax the Validation-only constraint (bundle.rs:353) — or add a sibling ResultsStore — so test/holdout/final partitions persist.

  2. Add a persisted MetricRecord table (from the already-computed RegressionMetricReport, metrics.rs:100) — the one genuinely new artifact.

  3. Add the Arrow/columnar on-disk write + an index for analytical queries.


4. Impact

4a. On the frozen 0.9.x nirs4all contract (nirs4all/CLAUDE.md:5)

The workspace SQLite/Parquet schemas, run-manifest layout, and .n4a format are stable within 0.9.x. The native store must not attempt to be that format. Migration path (ADR-02 schema-evolution SLA + ADR-17 cutover): native store is the source of truth; nirs4all keeps a thin Python shim that mirrors native predictions/scores into the legacy SQLite+Parquet workspace during the dual-run window, so Studio and existing .n4a consumers see no change. The joblib artifact wall is untouched (out of scope). This is a bridge, consistent with ADR-14’s managed-debt exception — but note the conflict with nirs4all’s no-shims rule (war-room open decision #3) is the maintainer’s call.

4b. On dag-ml’s bundle.rs

Additive: lift one validation constraint, add a metric-record store, add an optional Arrow writer. ~No churn to the existing serde types or the replay path. Consolidation toward dag-ml, not a fork.

4c. On the migration timeline

Option A rides the existing ADR-10 release train (dag-ml-data → dag-ml → nirs4all). Option C adds a fourth package to that train + a new cross-repo contract to keep JSON-identical — directly contrary to the war-room’s “fewer moving parts” posture and the “don’t reinvent dag-ml” warning (Tier-3 trap, war-room README:86-95).

4d. On performance

Today’s cache store is serde_json (text); Arrow/columnar would be faster than both JSON and the nirs4all read-modify-write-whole-Parquet append (array_store.py:350-357). But note the war-room caveat: dag-ml perf is only sanity-probed, not benchmarked (war-room README:129). Any “fast” claim must be benchmarked against the SQLite+Parquet baseline before it is asserted.

4e. On dependency weight

Option A in dag-ml-core keeps the core pure (serde_json + sha2); Arrow is opt-in and isolated (precedent: dag-ml-data-arrow). A standalone repo would re-pull serde/sha2/arrow and re-implement the fingerprint contract — more total dependency surface across the ecosystem, not less.

4f. On maintenance / another-repo cost

A new repo = new CI green-gate, license headers (the dual-CeCILL/AGPL + commercial policy), validate_contracts.py cross-repo parity job, release mechanism, RTD docs, and a contract that must stay byte-identical with dag-ml’s prediction-cache schemas. This is the single largest hidden cost and the strongest argument against Option C.


5. Merit / bien-fondé — recommendation

The capability is justified; a separate project is not.

  • The cross-language predictions+scores persistence goal is real and aligns with the Migration Mandate, which explicitly assigns “prediction/score persistence” to dag-ml (AGENTS.md:17) so “every binding (Python/R/MATLAB/WASM) gets it for free” (AGENTS.md:20-21).

  • But ~80% of it (prediction tables, manifests, fingerprints, replay) already exists in bundle.rs + runtime.rs + provenance.rs with the exact light-dep profile the proposal wants (serde_json + sha2). A new repo would duplicate this.

  • The genuinely-new work — persisted score/metric records, all-partition predictions, a columnar/queryable surface — is small, additive, and lands cleanly inside dag-ml.

  • The proposed placement (io ↔ dag-ml-data) is on the input seam; predictions/scores are outputs and belong downstream of the model run. Both neighbors’ charters explicitly disclaim this (nirs4all-io/CLAUDE.md:156-158; dag-ml-data/docs/ARCHITECTURE.md:53-58).

Recommendation (ranked)

  1. DO (recommended): Extend dag-ml’s prediction-cache into a predictions+scores store, as a submodule of dag-ml-core or a thin dag-ml-store crate inside the dag-ml workspace. Add (a) a persisted MetricRecord from the existing RegressionMetricReport, (b) all-partition support (relax bundle.rs:353), (c) an optional Arrow-IPC columnar/queryable representation (feature-gated, like dag-ml-data-arrow). Expose via the existing C ABI; bind per-language with the io/dag-ml pattern. nirs4all keeps a thin legacy-workspace mirror shim for the 0.9.x contract.

  2. Acceptable alternative: same code as a separate crate within the dag-ml workspace (not a separate repo) if the maintainer wants clearer module boundaries — keeps one repo, one release-train slot, one contract.

  3. Reject: a crate inside dag-ml-data (charter breach) or a standalone new repo “between io and dag-ml-data” (wrong seam, duplication, +1 release/contract/CI hop).


6. Risks & open questions for the maintainer

  1. Scores schema. No score/metric persistence schema exists yet (only node_result.schema.json has an optional metrics map). A new metric_record.schema.json must be designed and added to the conformance pack — decide its grain (per fold × target × metric).

  2. Validation-only constraint. Lifting bundle.rs:353 (cache = Validation-only) for a results store risks blurring the OOF-cache (a leakage-safety boundary) with a general results store. Keep them as distinct stores, or gate the relaxation carefully — do not weaken the OOF invariant (dag-ml/CLAUDE.md engineering rules).

  3. Arrow vs JSON canonical form. Two on-disk forms (JSON for replay/fingerprint + Arrow for query) risk drift. Decide which is canonical and whether the Arrow form is derived-and-disposable or first-class.

  4. Perf is unmeasured. The “fast” claim is unproven (war-room README:129). Benchmark against SQLite+Parquet before asserting it.

  5. Back-compat posture. The legacy-workspace mirror shim conflicts with nirs4all’s absolute no-shims rule (war-room open decision #3) — only the maintainer can sanction the ADR-14 managed-debt exception.

  6. Artifacts stay out. Confirm the layer is predictions/scores only; the joblib→neutral-model-format problem (ONNX / nirs4all-methods / dag-ml handles) is separate and must not be smuggled in.

  7. Reducer-vocabulary drift. robust_mean/exclude_outliers exist data-side only (war-room README:132); a scores/agg store must reconcile reducer names on the release train.


Provenance: grounded in nirs4all/pipeline/storage/*, nirs4all/data/predictions.py, dag-ml-core/src/{bundle,runtime,aggregation,metrics,oof,selection,provenance,policy}.rs, dag-ml-cli/src/main.rs, dag-ml-data/{CLAUDE.md,docs/ARCHITECTURE.md,crates}, nirs4all-io/{CLAUDE.md,COMPAT.md,crates}. All file:line cited inline. 2026-06-23.