fix(ml): one named example capacity; train() and trainAsync() no longer diverge

Phase 2, S35. Two real defects from one root cause, both confirmed by trace
rather than taken from the audit:

1. Divergence. WasmIML built its TS Dataset mirror with a cap of 100 while
   every addExample() ALSO pushed into the C++ FIFO ring, capped at 128. Since
   train() reads the C++ ring and trainAsync() reads the TS mirror, past 100
   examples the two trained on different datasets — silently.
2. Latent OOB read. nisps_ml_train sizes its sample-weight span by the C++
   side's example_count() (up to 128), but wasm-iml.ts allocates that heap
   buffer from the TS dataset's size (<=100). Once the ring exceeds the mirror,
   the span reads past the end of the caller's allocation.

Fix: name the capacity ONCE as nisps::ml::kDefaultMaxExamples = 128, used by
FixedStorage's default template arg, DynamicStorage's default ctor arg, and the
MLP<> alias (which is the only real FixedStorage instantiation path and carried
its own independent 128 literal — the last copy of this dual truth). Expose it
through nisps_ml_describe and have the TS side read it instead of hardcoding.
Dataset's constructor default is removed entirely: a default was what invited
this bug class, and the sole call site now always supplies the describe() value.

ABI NOTE: this extends nisps_ml_describe from a 6-int to a 7-int descriptor.
nisps_ml_describe always writes 7 ints regardless of the caller's buffer, so
every call site had to grow in the same change or it would overflow the WASM
heap by 4 bytes per call. All five sites updated: three in wasm-iml.ts (init
defaults, init per-instance, reshape re-describe — the finding said there were
two), one in wasm-worker.ts, one in tests/cpp/parity_wasm.mjs. The parity
harness's expected-dims check now also pins the new max_examples slot.

Regression test: tests/cpp/test_mlp_storage_defaults.cpp — pins the two storage
policies to one constant, and drives MLPCore<DynamicStorage> exactly as
bindings.cpp does past the old TS cap, asserting it saturates at 128 and not at
100. Fail-before/pass-after confirmed by temporarily setting the constant to
100: 2 failures, named. Reverted: green.

Audit correction: the cited dataset.ts:81 is the FIFO eviction check; the
hardcoded default was at dataset.ts:45.

Gates: run-all-tests.sh ALL GREEN, parity PASS.
This commit is contained in:
monkey-w1n5t0n 2026-07-21 13:22:38 +02:00
parent bf3d088ff1
commit dbe0f5d8ba
12 changed files with 218 additions and 36 deletions

Binary file not shown.

View file

@ -42,7 +42,19 @@ export class Dataset {
private labels_: Float32Array = new Float32Array(0);
private size_ = 0;
constructor(maxSize = 100) {
/**
* `maxSize` has NO default on purpose: this store mirrors the C++ MLP's
* example ring buffer 1:1 (FIFO eviction order, sample-weight indexing),
* so its cap must always come from the C++ side's actual `max_examples()`
* (via `nisps_ml_describe`) rather than a value picked independently here.
* A default invited exactly that divergence see S35
* (docs/specs/recon/simplification-audit-2026-07.md): the JS mirror
* defaulted to 100 while the C++ ring defaulted to 128, so train() and
* trainAsync() silently trained on different data past 100 examples, and
* sample-weight buffers sized to the JS side's count read out of bounds
* on the C++ side once its count exceeded that.
*/
constructor(maxSize: number) {
if (maxSize <= 0) throw new Error('Dataset.maxSize must be > 0');
this.maxSize = maxSize;
}

View file

@ -56,7 +56,11 @@ export interface NispsModule {
_nisps_ml_set_weights(ml: number, in_ptr: number): void;
_nisps_ml_draw_weights(ml: number, spread: number): void;
_nisps_ml_get_layer_stats(ml: number, out_ptr: number): void;
// Null ml reports the DEFAULT shape; a handle reports its runtime shape.
// Writes 7 ints: [in, h1, h2, h3, out, n_layers, max_examples]. Null ml
// reports the DEFAULT shape; a handle reports its runtime shape.
// max_examples is the C++ example-store ring-buffer capacity — the JS
// `Dataset` mirror MUST be sized to this value, not a hardcoded literal
// (see S35, docs/specs/recon/simplification-audit-2026-07.md).
_nisps_ml_describe(ml: number, out_ptr: number): void;
// ML feedback — the "Down Action" state machine (Avoid / RandomiseOutputs /
@ -169,6 +173,8 @@ export interface MLArchitecture {
hidden: [number, number, number];
outputSize: number;
numLayers: number;
/** C++ example-store ring-buffer capacity — sizes the JS `Dataset` mirror. */
maxExamples: number;
}
/** Per-layer weight health record (one per layer). */

View file

@ -41,6 +41,14 @@ import { createTrainer, type WasmTrainer } from './wasm-worker';
/** Default architecture matches `nisps/wasm/bindings.cpp` instantiation. */
const DEFAULT_INPUT_SIZE = 2;
const DEFAULT_OUTPUT_SIZE = 126;
/**
* Placeholder only overwritten by `init_()` with the live value read from
* `nisps_ml_describe` (out_dims[6]). Matches `nisps::ml::kDefaultMaxExamples`
* (nisps/ml/storage.hpp), the single source of truth for the C++ example-
* store ring-buffer capacity. Do NOT hardcode a different number for the JS
* `Dataset` mirror's cap see S35 (docs/specs/recon/simplification-audit-2026-07.md).
*/
const DEFAULT_MAX_EXAMPLES = 128;
/** Base-aware absolute URL for an asset served from `public/`. Resolves against
* `document.baseURI` (the page URL) so a `base: './'` build works under any
@ -116,6 +124,15 @@ export interface WasmIMLOptions {
seed?: number;
/** localStorage key the loaded weights/dataset will be persisted under. */
storageKey?: string;
/**
* Currently a no-op: `nisps_ml_create` has no max_examples parameter, so
* the C++ example-store ring buffer is always sized to
* `nisps::ml::kDefaultMaxExamples`, and the JS `Dataset` mirror must match
* it exactly (S35) there is no way to honour a caller-supplied override
* without also plumbing one through the C API. Kept on the options type so
* a future create()-side max_examples parameter has somewhere to land;
* unused today.
*/
maxExamples?: number;
/** Injected side-effect boundary. Defaults to a no-op sink (headless use). */
sink?: EngineSink;
@ -131,6 +148,7 @@ export class WasmIML {
hidden: [10, 14, 18],
outputSize: DEFAULT_OUTPUT_SIZE,
numLayers: 4,
maxExamples: DEFAULT_MAX_EXAMPLES,
};
private featuresBuf!: HeapBuffer;
@ -153,7 +171,13 @@ export class WasmIML {
private curveBuf!: HeapBuffer; // curve batch scratch (chunked)
private static CURVE_CHUNK = 256;
readonly dataset: Dataset;
// Constructed in init_(), once the live max_examples() is known from
// nisps_ml_describe — NOT in the constructor, which runs before the WASM
// module is loaded. Sizing this any other way (e.g. a hardcoded literal)
// is exactly the S35 bug: it must always match the C++ ring buffer's
// actual capacity or train()/trainAsync() silently diverge and
// sample-weight buffers sized to it read out of bounds C++-side.
dataset!: Dataset;
private readonly sink: EngineSink;
private lastLoss_: number | null = null;
private trainer: WasmTrainer | null = null;
@ -164,7 +188,6 @@ export class WasmIML {
static MAX_BATCH = 4096;
private constructor(opts: WasmIMLOptions) {
this.dataset = new Dataset(opts.maxExamples ?? 100);
this.storageKey = opts.storageKey ?? 'nisps:wasm-iml';
this.sink = opts.sink ?? noopSink;
}
@ -184,9 +207,10 @@ export class WasmIML {
// Default shape (null handle). Since one-core-engine P2 the MLP is
// runtime-shaped: create() honours requested dims; we pass the caller's
// sizes (falling back to the defaults) and re-describe the instance.
this.describePtr = this.module._malloc(6 * 4);
// 7 ints: [in, h1, h2, h3, out, n_layers, max_examples] (S35).
this.describePtr = this.module._malloc(7 * 4);
this.module._nisps_ml_describe(0, this.describePtr);
const defaults = new Int32Array(this.module.HEAP32.buffer, this.describePtr, 6);
const defaults = new Int32Array(this.module.HEAP32.buffer, this.describePtr, 7);
const wantedIn = opts.inputSize ?? defaults[0];
const wantedOut = opts.outputSize ?? defaults[4];
@ -195,14 +219,19 @@ export class WasmIML {
if (!this.mlHandle) throw new Error('[wasm-iml] nisps_ml_create returned null');
this.module._nisps_ml_describe(this.mlHandle, this.describePtr);
const dims = new Int32Array(this.module.HEAP32.buffer, this.describePtr, 6);
const dims = new Int32Array(this.module.HEAP32.buffer, this.describePtr, 7);
this.arch_ = {
inputSize: dims[0],
hidden: [dims[1], dims[2], dims[3]],
outputSize: dims[4],
numLayers: dims[5],
maxExamples: dims[6],
};
// The JS Dataset mirror's cap MUST equal the C++ ring buffer's actual
// max_examples() — read from describe(), never hardcoded (S35).
this.dataset = new Dataset(this.arch_.maxExamples);
this.weightCount_ = this.module._nisps_ml_weight_count(this.mlHandle);
this.featuresBuf = new HeapBuffer(this.module, this.arch_.inputSize);
@ -327,14 +356,19 @@ export class WasmIML {
// Re-describe the (new) instance and refresh the weight count.
this.module._nisps_ml_describe(this.mlHandle, this.describePtr);
const d = new Int32Array(this.module.HEAP32.buffer, this.describePtr, 6);
const d = new Int32Array(this.module.HEAP32.buffer, this.describePtr, 7);
this.arch_ = {
inputSize: d[0],
hidden: [d[1], d[2], d[3]],
outputSize: d[4],
numLayers: d[5],
maxExamples: d[6],
};
this.weightCount_ = this.module._nisps_ml_weight_count(this.mlHandle);
// nisps_ml_reshape never varies max_examples (no such parameter exists on
// that C API) — it always reconstructs at kDefaultMaxExamples, same as
// create(). The Dataset mirror's cap is therefore still correct; only its
// contents are cleared below, matching the C++ side's dataset reset.
// Reallocate every dim-dependent heap buffer. Freeing first then reallocating
// means a later malloc may sbrk-grow the heap and detach earlier views, so we

View file

@ -212,9 +212,15 @@ if (isWorker) {
workerSeed = seed >>> 0;
mlHandle = mod._nisps_ml_create(0, 0, 0, 0, workerSeed);
weightCount = mod._nisps_ml_weight_count(mlHandle);
const dPtr = mod._malloc(6 * 4);
// 7 ints: [in, h1, h2, h3, out, n_layers, max_examples] — the buffer size
// and view length here MUST track nisps_ml_describe's actual output
// (nisps/wasm/bindings.cpp) or this overflows the WASM heap by 4 bytes
// (S35, docs/specs/recon/simplification-audit-2026-07.md). This worker
// doesn't use max_examples (it never touches the dataset), but describe()
// always writes all 7 regardless of what the caller reads.
const dPtr = mod._malloc(7 * 4);
mod._nisps_ml_describe(mlHandle, dPtr);
const d = new Int32Array(mod.HEAP32.buffer, dPtr, 6);
const d = new Int32Array(mod.HEAP32.buffer, dPtr, 7);
netInputSize = d[0];
netOutputSize = d[4];
netHidden = [d[1], d[2], d[3]];

View file

@ -56,6 +56,7 @@ if(NOT EMSCRIPTEN)
${NISPS_TEST_DIR}/test_mlp_loss.cpp
${NISPS_TEST_DIR}/test_mlp_rl.cpp
${NISPS_TEST_DIR}/test_mlp_storage_parity.cpp
${NISPS_TEST_DIR}/test_mlp_storage_defaults.cpp
${NISPS_TEST_DIR}/test_mlp_jolt.cpp
${NISPS_TEST_DIR}/test_mlp_ou_noise.cpp
${NISPS_TEST_DIR}/test_mlp_feedback.cpp

View file

@ -27,7 +27,7 @@
#include <new>
#include <span>
#include "storage.hpp" // kMlpNumLayers
#include "storage.hpp" // kMlpNumLayers, kDefaultMaxExamples
namespace nisps::ml {
@ -40,7 +40,7 @@ class DynamicStorage {
DynamicStorage(std::size_t n_in,
std::span<const std::size_t> hidden,
std::size_t n_out,
std::size_t max_examples = 128u,
std::size_t max_examples = kDefaultMaxExamples,
std::size_t max_iter_train = 4096u) noexcept {
if (hidden.size() != 3u || n_in == 0u || n_out == 0u ||
hidden[0] == 0u || hidden[1] == 0u || hidden[2] == 0u) {

View file

@ -568,7 +568,7 @@ template <std::size_t NIn,
std::size_t NHidden2,
std::size_t NHidden3,
std::size_t NOut,
std::size_t NMaxExamples = 128u,
std::size_t NMaxExamples = kDefaultMaxExamples,
std::size_t NMaxIterTrain = 4096u>
using MLP = MLPCore<
FixedStorage<NIn, NHidden1, NHidden2, NHidden3, NOut, NMaxExamples, NMaxIterTrain>>;

View file

@ -47,12 +47,21 @@ namespace nisps::ml {
inline constexpr std::size_t kMlpNumLayers = 4u;
// Default example-store capacity, named ONCE and shared by FixedStorage's
// compile-time default (below) and DynamicStorage's runtime-default
// constructor argument (nisps/ml/dynamic_storage.hpp). `nisps_ml_describe`
// (nisps/wasm/bindings.cpp) reports the live instance's max_examples() so
// the Manifold TS side (manifold/src/engine/wasm-iml.ts) can size its JS
// Dataset mirror to match instead of hardcoding a second, divergent number
// (see docs/specs/recon/simplification-audit-2026-07.md S35).
inline constexpr std::size_t kDefaultMaxExamples = 128u;
template <std::size_t NIn,
std::size_t NHidden1,
std::size_t NHidden2,
std::size_t NHidden3,
std::size_t NOut,
std::size_t NMaxExamples = 128u,
std::size_t NMaxExamples = kDefaultMaxExamples,
std::size_t NMaxIterTrain = 4096u>
class FixedStorage {
public:

View file

@ -868,10 +868,14 @@ void nisps_ml_clear_examples(void* ml) {
h->mlp.clear_examples();
}
// Architecture introspection — writes [in, h1, h2, h3, out, n_layers] into a
// caller-supplied int buffer. Always 6 ints. With a null handle it reports
// the DEFAULT shape (what create() yields for non-positive args); with a
// handle it reports that instance's actual runtime shape.
// Architecture introspection — writes [in, h1, h2, h3, out, n_layers,
// max_examples] into a caller-supplied int buffer. Always 7 ints. With a
// null handle it reports the DEFAULT shape (what create() yields for
// non-positive args); with a handle it reports that instance's actual
// runtime shape. max_examples is the example-store (dataset) ring-buffer
// capacity (nisps::ml::kDefaultMaxExamples, nisps/ml/storage.hpp) — the
// single source of truth the TS Dataset mirror sizes itself to instead of
// hardcoding a second, potentially-divergent number (S35).
EMSCRIPTEN_KEEPALIVE
void nisps_ml_describe(void* ml, int* out_dims) {
if (!out_dims) return;
@ -882,6 +886,7 @@ void nisps_ml_describe(void* ml, int* out_dims) {
out_dims[3] = static_cast<int>(kDefaultHidden[2]);
out_dims[4] = static_cast<int>(kDefaultOutputs);
out_dims[5] = static_cast<int>(BrowserMLP::kNumLayers);
out_dims[6] = static_cast<int>(nisps::ml::kDefaultMaxExamples);
return;
}
auto* h = static_cast<MLHandle*>(ml);
@ -891,6 +896,7 @@ void nisps_ml_describe(void* ml, int* out_dims) {
out_dims[3] = static_cast<int>(h->mlp.fan_out(2u));
out_dims[4] = static_cast<int>(h->mlp.n_out());
out_dims[5] = static_cast<int>(BrowserMLP::kNumLayers);
out_dims[6] = static_cast<int>(h->mlp.max_examples());
}
// ---------------------------------------------------------------------------

View file

@ -7,19 +7,12 @@
* The WASM module is loaded from manifold/public/nisps.{js,wasm}
* scripts/build-wasm.sh must have run first.
*
* Output blob format matches parity_check.cpp:
* uint32 magic = 'NPRT'
* uint32 version = 1
* uint32 n_floats
* float32[n_floats] payload
*
* Payload order:
* 126 outputs (stage 1: post-process at (0.25, 0.75))
* 12 weights (probed at fixed indices)
* 126 outputs (stage 2: post-train, re-process)
* 1 final training loss
* 2 PAFSynth L+R means (silence input, 128 samples)
* 2 ChannelStrip L+R means (0.25 input, 128 samples)
* Blob format and payload order are defined by parity_check.cpp its header
* and `---- Stage N ----` sections are the authoritative stage list (seven
* stages: ML inference, ML training, PAFSynth, ChannelStrip, feedback
* controller, geometric dislike, pipelines + curves). Format: magic 'NPRT',
* version 5, n_floats, float32 payload. Keep the two drivers in lockstep and
* bump VERSION in both (and in parity_diff.mjs) on any layout change.
*
* Exit codes:
* 0 success
@ -197,13 +190,17 @@ async function main() {
const api = bind(Module);
// Verify dimensions match the native side. A null handle reports the
// DEFAULT shape (what create() yields for non-positive args).
const dimsBuf = api.malloc(6 * 4);
// DEFAULT shape (what create() yields for non-positive args). 7 ints:
// [in, h1, h2, h3, out, n_layers, max_examples] (S35 — the buffer size
// and view length below MUST track nisps_ml_describe's actual output or
// this silently overflows the WASM heap by 4 bytes).
const dimsBuf = api.malloc(7 * 4);
api.describe(0, dimsBuf);
const dims = new Int32Array(Module.HEAP32.buffer, dimsBuf, 6).slice();
const dims = new Int32Array(Module.HEAP32.buffer, dimsBuf, 7).slice();
api.free(dimsBuf);
// Expect: [32, 10, 14, 18, 126, 4] (32-input max for mix-and-match)
const expectedDims = [32, 10, 14, 18, 126, 4];
// Expect: [32, 10, 14, 18, 126, 4, 128] (32-input max for mix-and-match;
// 128 = nisps::ml::kDefaultMaxExamples, nisps/ml/storage.hpp)
const expectedDims = [32, 10, 14, 18, 126, 4, 128];
for (let i = 0; i < expectedDims.length; ++i) {
if (dims[i] !== expectedDims[i]) {
console.error(`[parity_wasm] WASM build has dim[${i}]=${dims[i]}, native expected ${expectedDims[i]}`);

View file

@ -0,0 +1,111 @@
// tests/cpp/test_mlp_storage_defaults.cpp — regression test for S35 (dual
// example-store cap mismatch), docs/specs/recon/simplification-audit-2026-07.md.
//
// THE BUG (confirmed by trace, not just the audit's description): Manifold's
// WasmIML kept a JS `Dataset` mirror hardcoded to `maxSize = 100`
// (manifold/src/engine/dataset.ts / wasm-iml.ts) while pushing every example
// into the SAME C++ FIFO ring (`MLPCore<DynamicStorage>`, the browser MLP —
// nisps/wasm/bindings.cpp `BrowserMLP`) whose cap defaulted to 128
// (nisps/ml/dynamic_storage.hpp). Past 100 examples the two stores held
// DIFFERENT data: `train()` reads the C++ ring (up to 128 examples) while
// `trainAsync()` reads the JS Dataset (capped at 100) — silently diverging.
// The same mismatch produced a latent OOB read: `nisps_ml_train`
// (bindings.cpp) builds `std::span<const float>(sample_weights,
// mlp.example_count())` — sized to the C++ side's count (up to 128) — over a
// buffer the JS side allocated at its own (<=100) example count.
//
// THE FIX: name the shared capacity ONCE — `nisps::ml::kDefaultMaxExamples`
// (nisps/ml/storage.hpp) — used as FixedStorage's default template argument
// AND DynamicStorage's default constructor argument (nisps/ml/dynamic_storage.hpp),
// and exposed through `nisps_ml_describe`'s extended (7-int) dims descriptor
// so the TS side reads it instead of hardcoding a second, divergent literal.
//
// This test lives entirely on the C++ side (the WASM bindings link against
// emscripten headers and aren't part of the native ctest build), so it
// cannot exercise the TS Dataset mirror directly. What it CAN and DOES pin:
// 1. FixedStorage's and DynamicStorage's DEFAULT capacities are the same
// named constant, not two independently-hardcoded literals that could
// silently drift apart (the exact bug shape, reproduced within reach
// of a native test).
// 2. The actual runtime ring — constructed exactly as
// nisps/wasm/bindings.cpp's MLHandle constructs the browser MLP (no
// max_examples argument passed) — saturates AT that constant, not at
// the old wrong TS-side literal (100) and not unbounded.
#include <cstddef>
#include <span>
#include "../../nisps/ml/dynamic_storage.hpp"
#include "../../nisps/ml/mlp.hpp"
#include "test_helpers.hpp"
namespace {
constexpr std::size_t kIn = 3u;
constexpr std::size_t kH1 = 4u;
constexpr std::size_t kH2 = 5u;
constexpr std::size_t kH3 = 6u;
constexpr std::size_t kOut = 2u;
constexpr std::uint64_t kSeed = 0xC0FFEEu;
} // namespace
// The two storage policies' DEFAULT example-store capacity must be the SAME
// named constant. If a future edit hardcodes a new literal in one place
// without updating the other, this fails immediately — no examples need to
// be pushed to catch it.
NISPS_TEST(storage_defaults_share_one_named_capacity) {
// FixedStorage's own default template argument (storage.hpp).
using DefaultFixedStorage =
nisps::ml::FixedStorage<kIn, kH1, kH2, kH3, kOut>; // NMaxExamples defaulted
NISPS_EXPECT(DefaultFixedStorage::kMaxExamples == nisps::ml::kDefaultMaxExamples);
// DynamicStorage's own default constructor argument — this is the exact
// path nisps/wasm/bindings.cpp's MLHandle uses for the browser MLP (no
// max_examples argument passed to the MLPCore ctor).
const std::size_t hidden[3] = {kH1, kH2, kH3};
nisps::ml::MLPCore<nisps::ml::DynamicStorage> dyn(
kSeed, kIn, std::span<const std::size_t>(hidden), kOut);
NISPS_ASSERT(dyn.valid());
NISPS_EXPECT(dyn.max_examples() == nisps::ml::kDefaultMaxExamples);
// Cross-policy: the two independently-defaulted storage models must
// agree — this IS the invariant S35 violated across the C++/TS boundary.
NISPS_EXPECT(DefaultFixedStorage::kMaxExamples == dyn.max_examples());
// Pin the actual value: this is what nisps_ml_describe reports (out_dims[6])
// and what every WASM caller must size its JS Dataset mirror to — 128, NOT
// the 100 the TS side used to hardcode.
NISPS_EXPECT(nisps::ml::kDefaultMaxExamples == 128u);
}
// Push more than the OLD (wrong) TS-side cap of 100 examples through a
// default-constructed DynamicStorage MLP (mirroring MLHandle's construction
// exactly — no max_examples argument). The ring must saturate at
// kDefaultMaxExamples (128), matching what describe() reports — not at 100,
// and not unbounded. Before the S35 fix, Manifold's JS Dataset mirror capped
// at a hardcoded 100 while this ring kept growing past it: past 100
// examples the two stores held different data (train() vs trainAsync()),
// and a sample-weight buffer sized to the JS side's (<=100) count would read
// out of bounds against this ring's (up to 128) example_count().
NISPS_TEST(storage_defaults_ring_caps_at_shared_constant_past_old_ts_cap) {
const std::size_t hidden[3] = {kH1, kH2, kH3};
nisps::ml::MLPCore<nisps::ml::DynamicStorage> dyn(
kSeed, kIn, std::span<const std::size_t>(hidden), kOut);
NISPS_ASSERT(dyn.valid());
constexpr std::size_t kOldWrongTsCap = 100u;
const std::size_t push_count = nisps::ml::kDefaultMaxExamples + 5u; // 133 > 128 > 100
NISPS_ASSERT(push_count > kOldWrongTsCap);
float feat[kIn];
float lab[kOut];
for (std::size_t e = 0; e < push_count; ++e) {
for (std::size_t i = 0; i < kIn; ++i) feat[i] = 0.01f * static_cast<float>((e + i) % 17u);
for (std::size_t i = 0; i < kOut; ++i) lab[i] = 0.02f * static_cast<float>((e + i) % 11u);
dyn.add_example(std::span<const float>(feat, kIn), std::span<const float>(lab, kOut));
}
NISPS_EXPECT(dyn.example_count() == nisps::ml::kDefaultMaxExamples);
NISPS_EXPECT(dyn.example_count() != kOldWrongTsCap);
}