@nbt-dev/seed
v0.1.0
Published
Deterministic synthetic-data generation for NBT cartridges: seeded randomness, distributions, fixed-offset calendars, funnel walks and a row planner.
Readme
@nbt-dev/seed
Deterministic synthetic data for NBT cartridges. Same seed, same rows, every time.
npm install @nbt-dev/seedRead this before you import it
Inside a cartridge, use a namespace import.
import * as seed from "@nbt-dev/seed"; // correct
import { rng } from "@nbt-dev/seed"; // SILENTLY WRONGA cart's action, workflow and view sources are rewritten before they reach esbuild:
every import { X } from "src" becomes const X = __nbtEntity("src:X"), because that
is how a cart names an entity in another cart. There is no exception for a package
specifier and no error — the named form compiles green and hands you undefined at
run time. The namespace and default forms are left alone, and esbuild resolves and
tree-shakes them normally.
Ordinary Node, Deno, a *.seed.ts, a build script: either form is fine.
What it is
An engine, not a dataset. It holds no business numbers, no taxonomies and no domain knowledge — rates, mixes and stage names are yours, passed in as data. Keep them in the harness that owns them: this package publishes public, so anything in it is on the registry regardless of who bought what.
Four entry points, so a caller who wants a lognormal is not carrying a name pool:
| import | what |
|---|---|
| @nbt-dev/seed | seeded randomness, substreams, common random numbers, distributions, ids |
| @nbt-dev/seed/time | fixed-offset calendars, booking slots, lead times, settle lags, seasonality |
| @nbt-dev/seed/people | synthetic names, addresses, emails, phone numbers |
| @nbt-dev/seed/funnel | stage walks, a defect layer, and a row planner |
A whole dataset from one number
import * as seed from "@nbt-dev/seed";
import * as time from "@nbt-dev/seed/time";
import * as people from "@nbt-dev/seed/people";
import * as funnel from "@nbt-dev/seed/funnel";
const FUNNEL = {
start: "new",
edges: {
new: [{ to: "booked", p: 0.28, lag: { medianDays: 1, p90Days: 9 } }],
booked: [{ to: "showed", p: 0.66, lag: { medianDays: 3, p90Days: 14 } },
{ to: "noshow", p: 0.15, lag: { medianDays: 3, p90Days: 14 } }],
showed: [{ to: "sold", p: 0.088, lag: { medianDays: 5.2, p90Days: 66.6 } }],
},
};
export function build(rootSeed: number, count: number, nowMs: number) {
const s = seed.streams(rootSeed);
const clock = time.clock(nowMs, -300); // one location, one offset
const plan = funnel.plan();
for (let i = 0; i < count; i++) {
const who = people.contact(s.of("contact", i), "northwind.example");
const ref = plan.add("crm:Contact", { name: who.name, email: who.email, phone: who.phone });
const steps = funnel.walk(s.of("funnel", i), FUNNEL, time.localMidnight(clock, -120));
if (!funnel.reached(steps, "booked")) continue;
const day = time.pickDayOffset(s.of("day", i), clock, {
from: -30, to: 10, dowWeights: [0, 1, 1, 1, 1, 1, 0.4], // no Sundays
});
plan.add("crm:Appointment", {
contact: ref, // resolved to the created id
startsAt: time.slot(s.of("slot", i), clock, day, { hourWeights: [[9, 64], [10, 75], [14, 18]] }),
outcome: funnel.finalStage(steps),
});
}
return plan;
}plan.count() is a dry run — rows per entity, nothing written. plan.apply(writer)
writes them in order and resolves the refs; writer is anything with
create(entity, row) => Promise<{id}>, which SeedCtx from @nbt-dev/harness/seed
already is.
For a large dataset, page it and carry the refs forward, or paging breaks every reference across the seam:
let refs = {};
for (let from = 0; from < plan.length; from += 500)
refs = await plan.apply(writer, { from, count: 500, refs });Four things it refuses to let you get wrong
Substreams, not one cursor. streams(seed).of("contact", 42) is independent of
everything else. With a single shared generator, inserting one row upstream rewrites
the whole dataset and a resumed run produces different data than an uninterrupted one.
A fixed offset, never a timezone. time takes offsetMinutes and does UTC
arithmetic under it. There is no Intl in QuickJS, so toLocaleString and
localeCompare are absent or silently different inside a console. And the boundary is
where this actually bites: on a real appointment corpus, 120 of 8,685 rows fall on a
different weekday in UTC than in the location's own time — enough to reorder a
day-of-week ranking and give a confident answer to the wrong question.
Median and p90, never a mean. lognormalFrom(5.2, 66.6) and time.lag(...) take
the two numbers people actually measure. An exponential forces p90/p50 = 3.32; a real
settle time runs nearer 13, and fitting the exponential throws away the tail, which is
the half anyone cares about. The signature will not accept a rate.
Defects on purpose. funnel.defect(r, 0.7, damage) exists because data that reads
as real has the flaws real data has — a spelling that changed the year the form did, a
backfill collapsing most of a date column onto one day, two ledgers of the same fact
that disagree. A generator emitting only clean rows produces a demo where every
aggregate agrees, which is the one thing production never does.
Constraints on this package's own source
It gets bundled into cartridges and scanned textually there, and the scanner cannot
tell library code from author code. So none of these may appear in dist/:
Date.now · Math.random · crypto.randomUUID · db[ · email[ · vector[ ·
Intl. · localeCompare · toLocaleString · node: · require(
The build refuses on any of them and npm test asserts it. Nothing here reads a clock
or an entropy source: ulidLike takes its epoch as an argument for exactly that reason.
Develop
npm install
npm run build # esbuild per module + tsc types + the forbidden-token scan
npm test # node --testLicence CPAL-1.0.
