@seminr/core
v0.2.1
Published
SEM (Structural Equation Modeling) estimation in TypeScript — PLS-SEM and covariance-based SEM (CBSEM/CFA), ported from the seminr R package
Maintainers
Readme
seminr-ts
SEM (Structural Equation Modeling) estimation in TypeScript: PLS-SEM and covariance-based SEM (CBSEM/CFA).
seminr-ts is a port of the modeling and estimation core of the
seminr R package: model specification DSL,
the simplePLS estimation algorithm, PLSc consistency correction, bootstrapping,
interaction terms, higher-order constructs, and covariance-based estimation
(CFA and full CBSEM). Where seminr delegates covariance-based estimation to
lavaan, seminr-ts implements the maximum-likelihood
estimator itself (LISREL matrices, analytic gradient, BFGS) and matches
lavaan's output.
It serves two kinds of users:
- Data analysts who want to run a complete SEM analysis — specify, estimate, assess, bootstrap, predict — in a modern scripting runtime (Bun, Node, or a notebook that runs TypeScript) instead of R. Start at Analyzing data with seminr-ts.
- Developers embedding SEM estimation in a product — a dashboard, a survey platform, a browser tool. The library is zero-dependency, runtime-agnostic ESM with TypeScript types. Start at Integrating seminr-ts into a product.
Status
Published to npm as @seminr/core.
Numerical parity with seminr on its bundled mobi / ECSI dataset is the
acceptance bar for every feature; golden fixtures are generated from the R
implementation.
- PLS-SEM: estimation (composite and reflective/PLSc models, path
weighting/factorial schemes, interactions, two-stage higher-order
constructs, bootstrapping) matches seminr at 1e-5, as do the
model-evaluation suite behind
summarizePls(reliability, HTMT, Fornell-Larcker, cross-loadings, VIFs, f², total indirect effects, AIC/BIC, descriptives), PLSpredict (predictPls), and PLS-MGA (estimatePlsMga). - CBSEM/CFA:
estimateCfaandestimateCbsem(withstd.lv = TRUEsemantics: ML point estimates, standardized solution, standard errors, fit measures, ten Berge construct scores, product-indicator and two-stage interactions, second-order factors viahigherReflective) match seminr/lavaan on the same fixtures. The default estimator is"MLR", matching seminr: robust (Huber-White sandwich) standard errors plus Yuan-Bentler-Mplus scaled and robust fit indices, withestimator: "ML"opting into plain expected-information inference. - Missing data is handled by
meanReplacement(default) ornaOmit, matching seminr'smissingargument. - Plotting:
plot(model)produces seminr's Graphviz path diagrams (specified/estimated/bootstrapped PLS, CBSEM/CFA, HTMT networks, the four theme variants) as DOT text that is byte-identical to R seminr's, rendered to SVG on demand via wasm Graphviz; the chart-style result plots (plotScores,plotReliabilityTable,slopeAnalysis,plotPredictError) are dependency-free SVG.
Analyzing data with seminr-ts
Install
bun add @seminr/core # or: npm install @seminr/coreThe npm package is named @seminr/core; the project and its repository are seminr-ts
(the TypeScript port of the seminr R package).
A complete PLS analysis
Load your data as CSV text from wherever your runtime provides it, then specify, estimate, summarize, and bootstrap:
import {
parseCsv,
constructs, composite, multiItems,
relationships, paths,
estimatePls, summarizePls,
bootstrapModel, summarizePlsBoot,
} from "@seminr/core";
// 1. Load data — any way of obtaining CSV text works
const data = parseCsv(await Bun.file("mobi.csv").text()); // Bun
// const data = parseCsv(await (await fetch("/mobi.csv")).text()); // browser
// 2. Measurement model: how each construct is measured
const measurementModel = constructs(
composite("Image", multiItems("IMAG", [1, 2, 3, 4, 5])),
composite("Expectation", multiItems("CUEX", [1, 2, 3])),
composite("Satisfaction", multiItems("CUSA", [1, 2, 3])),
);
// 3. Structural model: which constructs predict which
const structuralModel = relationships(
paths({ from: ["Image", "Expectation"], to: "Satisfaction" }),
);
// 4. Estimate and assess
const model = estimatePls({ data, measurementModel, structuralModel });
const summary = summarizePls(model);
summary.paths; // R², adjusted R², and path coefficients per outcome
summary.reliability; // alpha, rhoA, rhoC, AVE per construct
summary.validity.htmt; // discriminant validity (HTMT)
summary.fSquare; // f² effect sizes
// 5. Bootstrap confidence intervals
const boot = bootstrapModel({ model, nboot: 1000, seed: 42 });
const bootSummary = summarizePlsBoot(boot);
bootSummary.bootstrappedPaths; // orig. est., boot mean/SD, t-stat, 95% CI per pathThis example runs as a test in tests/readme-example.test.ts.
Reading the output. Result tables are NamedMatrix values — plain
{ rows, cols, values } objects with named rows and columns. Read a single
cell with nmGet(matrix, row, col), e.g.
nmGet(summary.paths, "Image", "Satisfaction"), or render the whole table any
way you like (the demos include a text formatter that prints seminr's summary
layouts). Interpretation matches seminr: summary.paths stacks R²/AdjR² on
top of path coefficients per endogenous column, and
bootstrappedPaths columns are Original Est. / Bootstrap Mean / Bootstrap SD
/ T Stat. / 2.5% CI / 97.5% CI / Bootstrap P Val per structural path.
Beyond estimation and bootstrapping, the same assessment workflow seminr
offers is available: predictPls (k-fold cross-validated PLSpredict with an
LM benchmark), estimatePlsMga (multi-group analysis), and mediation helpers
(specificEffectSignificance, totalIndirectCi).
Coming from seminr (R)
The API deliberately mirrors seminr; the correspondence is mechanical:
| seminr (R) | seminr-ts (TypeScript) |
| --- | --- |
| mobi <- read.csv("mobi.csv") | const mobi = parseCsv(csvText) |
| multi_items("IMAG", 1:5) | multiItems("IMAG", [1, 2, 3, 4, 5]) |
| composite("Image", ...) | composite("Image", ...) |
| paths(from = "Image", to = "Loyalty") | paths({ from: "Image", to: "Loyalty" }) |
| estimate_pls(mobi, mm, sm) | estimatePls({ data: mobi, measurementModel, structuralModel }) |
| summary(model) | summarize(model) (or summarizePls) |
| bootstrap_model(model, nboot = 1000) | bootstrapModel({ model, nboot: 1000 }) |
| predict_pls(model, ...) | predictPls({ model, ... }) |
| estimate_pls_mga(model, condition) | estimatePlsMga({ model, condition }) |
| estimate_cbsem(mobi, mm, sm) | estimateCbsem({ data: mobi, measurementModel, structuralModel }) |
General rules: R's snake_case becomes camelCase, R vectors become arrays,
and R's named arguments become a single named-arguments object. Every DSL and
estimation entry point accepts that named form (paths, composite,
reflective, higherComposite, multiItems, interactionTerm,
quadraticTerm, estimatePls, bootstrapModel, bootstrapModelParallel,
estimateCfa, estimateCbsem; argument shapes are exported as PathsArgs,
CompositeArgs, ..., EstimateCbsemArgs). Positional forms mirroring the R
signatures work too — both are equivalent:
paths({ from: ["Image", "Expectation"], to: "Satisfaction" });
paths(["Image", "Expectation"], "Satisfaction"); // same resultOne deliberate difference: seminr's summary() printout becomes structured
data here (see "Reading the output" above), and bootstrap seeds are
deterministic within seminr-ts but do not reproduce R's random stream — see
Bootstrap reproducibility.
Covariance-based SEM (CBSEM / CFA)
The same model specification estimates as a covariance-based model — seminr
syntax first resolves to the same measurement/structural matrices the PLS
routines use, then a maximum-likelihood estimator (equivalent to
lavaan::sem(..., std.lv = TRUE)) processes them:
import {
constructs, reflective, multiItems, singleItem,
relationships, paths, associations, itemErrors,
estimateCfa, estimateCbsem, summarizeCbsem, nmGet,
} from "@seminr/core";
const mm = constructs(
reflective("Image", multiItems("IMAG", [1, 2, 3, 4, 5])),
reflective("Expectation", multiItems("CUEX", [1, 2, 3])),
reflective("Satisfaction", multiItems("CUSA", [1, 2, 3])),
reflective("Complaints", singleItem("CUSCO")),
);
// free selected inter-item error covariances
const am = associations(itemErrors("IMAG1", "CUEX2"));
// CFA of the measurement model
const cfa = estimateCfa({ data: mobi, measurementModel: mm, itemAssociations: am });
cfa.factorLoadings; // standardized loadings
cfa.constructScores; // ten Berge factor scores
cfa.lavaanModel; // the equivalent lavaan syntax string
// full structural model
const sm = relationships(
paths({ from: ["Image", "Expectation"], to: "Satisfaction" }),
paths({ from: "Satisfaction", to: "Complaints" }),
);
const cbsem = estimateCbsem({ data: mobi, measurementModel: mm, structuralModel: sm, itemAssociations: am });
nmGet(cbsem.pathCoef, "Image", "Satisfaction"); // standardized path coefficient
const summary = summarizeCbsem(cbsem);
summary.fit["cfi"]; // chisq, df, pvalue, cfi, tli, rmsea (+CI), srmr, aic, bic, ...
summary.reliability; // rhoC / AVE per construct
summary.paths; // est.std, se, z, p, CIs per parameterThis example runs as a test in tests/readme-example.test.ts. CBSEM
interactions support the productIndicator and twoStage methods, and
second-order factors are specified with higherReflective(name, dimensions).
Plotting
plot() accepts any seminr model — a measurement/structural spec, an
estimated or bootstrapped PLS model, or a CFA/CBSEM model — and returns a
SeminrPlot wrapping the Graphviz DOT source (byte-identical to R seminr's
dot_graph() output for the model families R supports; CFA/CBSEM diagrams
are a seminr-ts/py design, since R delegates those to semPlot):
import {
plot, plotHtmt, savePlot, seminrThemeDark, seminrThemeSet,
plotScores, plotReliabilityTable, slopeAnalysis, plotPredictError,
} from "@seminr/core";
const diagram = plot(model, { title: "ECSI (PLS)" });
diagram.dot; // Graphviz DOT text — always available
await diagram.toSvg(); // SVG string (wasm Graphviz, loaded on demand)
await diagram.save("ecsi.svg"); // or .dot / .gv (Bun/Node only)
await savePlot("last-plot.svg"); // saves the most recent plot, as R's save_plot()
plot(bootModel, { alpha: 0.01 }); // paths with stars + CIs
plotHtmt(bootModel, { htmtThreshold: 0.9 }); // HTMT discriminant network
plot(model, { theme: seminrThemeDark() }); // themes: default/academic/smart/dark
seminrThemeSet(seminrThemeDark()); // or set the active theme globallyRendering needs the optional peer dependency
@hpcc-js/wasm-graphviz
(bun add @hpcc-js/wasm-graphviz) — real Graphviz compiled to WebAssembly, so
toSvg() works in Bun, Node, and browsers alike. Without it, the .dot
source is still available and toSvg() rejects with an install hint.
toSvg()/save() are async (the wasm engine loads on first use) —
the one deliberate divergence from R's synchronous plotting API.
The chart-style result plots return an SvgPlot (.svg string plus
.save()) and need no renderer at all: plotScores(model) (score
scatterplot matrix), plotReliabilityTable(reliabilityTable(model)),
slopeAnalysis(model, dv, moderator, iv) / plotInteraction, and
plotPredictError(summarizePlsPredict(prediction), "CUSA1").
Integrating seminr-ts into a product
Data contract
Estimation consumes a Dataset: a plain
{ columns: string[], values: number[][] } object (row-major; NaN marks
missing values, handled by the missing strategy). Produce it however you
like — parseCsv(text) is a bundled convenience, not a requirement — and
subset it with the exported getColumn / selectColumns helpers. Results
come back as plain data too: NamedMatrix ({ rows, cols, values }, read
cells with nmGet) and typed summary records — everything is
JSON-serializable; nothing is a class instance.
Runtime support
The library is zero-dependency, runtime-agnostic ES modules — no node:* or
Bun.* APIs anywhere in src/ (a test-guarded browser-target bundle check
keeps it that way). It runs in Bun, Node, Deno, and web browsers.
TypeScript declarations ship in the package, sideEffects: false is set, so
bundlers can tree-shake unused estimators (e.g. shipping only PLS without the
CBSEM code).
Web Workers and bundlers
bootstrapModelParallel, predictPlsParallel, and estimatePlsMgaParallel
fan work out across Web Workers — the same API works in Bun and in browsers —
and return results identical to their sequential counterparts for the same
seed or indices:
import { bootstrapModelParallel } from "@seminr/core";
const boot = await bootstrapModelParallel(model, { nboot: 500, seed: 123 });
// options: workers (default: hardwareConcurrency - 1), plus everything
// bootstrapModel accepts (seed, indices, resampler, nboot)By default the worker module is spawned from
new URL("../workers/worker.js", import.meta.url) — a runtime URL naming the
compiled shared worker next to the published modules. Runtimes that resolve
node_modules URLs (Bun, Node) need no configuration. If your bundler emits
the worker elsewhere, pass a factory:
const boot = await bootstrapModelParallel(model, {
nboot: 500,
seed: 123,
createWorker: () =>
new Worker(new URL("./my-worker-bundle.js", import.meta.url), { type: "module" }),
});(The shared worker entry is @seminr/core/dist/workers/worker.js; bundle it with
--target browser for web use — the browser demo's serve.ts shows a working
setup.) Two serialization limits apply across the worker boundary: interaction
terms must use the builtin methods (productIndicator, orthogonal,
twoStage), and models estimated with a custom missing-data function must use
the sequential variants (the builtin meanReplacement and naOmit both
work).
Error behavior
Model-specification and data problems throw plain Errors with descriptive
messages (unknown columns, unreachable constructs, non-converging estimation);
there are no error codes or custom error classes yet. Numerical edge cases in
summaries follow seminr and R conventions: undefined cells (e.g. HTMT above
the diagonal) are NaN rather than omitted.
Versioning
Pre-1.0: minor versions (0.x → 0.y) may change the API; patch versions
are additive or fixes only. Pin a minor range (~0.1) if you need stability.
License obligations
seminr-ts is licensed GPL-3.0 (it is a derivative port of the GPL-3 seminr). GPL-3 is a copyleft license: distributing a product that bundles or links seminr-ts — including serving a bundled browser app — carries GPL-3 obligations for that product's code. Check GPL-3 compatibility with your licensing before shipping it in a closed-source product; using it server-side or internally, or for your own analyses, imposes nothing.
Demos
Runnable examples (mirroring seminr's demo/ scripts) live in demos/. They
consume the built package — they import "@seminr/core" exactly as an installed
consumer would — so build first:
bun run build
bun run demos/pls-ecsi.ts # full ECSI model + worker-parallel bootstrap
bun run demos/plsc-ecsi.ts # consistent PLS (PLSc) with reflective constructs
bun run demos/pls-interaction.ts # moderation via all three interaction methods
bun run demos/pls-higher-order.ts # two-stage higher-order construct
bun run demos/alternative-models.ts # comparing alternative structural models
bun run demos/pls-assessment.ts # evaluation suite, boot summary, PLSpredict, PLS-MGA
bun run demos/cbsem-cfa-ecsi.ts # covariance-based CFA + CBSEM with an interaction
bun run demos/plot-ecsi.ts # path diagrams (PLS/boot/HTMT/CBSEM) + SVG chart plots
bun run demos/browser/serve.ts # browser demo: estimation, bootstrap, and plotting in a web pageAll demos are exercised by tests/demos.test.ts.
Bootstrap reproducibility
bootstrapModel(model, { nboot, seed }) is deterministic for a given seed
within seminr-ts (mulberry32 PRNG), but does not reproduce R's random number
stream. For exact numerical agreement with a seminr bootstrap, pass the exact
resample indices R used via { indices } (0-based row indices, one array per
replication) — R's indices for seed are re-derivable as
set.seed(seed + i); sample.int(n, replace = TRUE) for replication i.
The parity test suite does exactly this.
Attribution and license
The algorithms, API design, and test fixtures derive from seminr by Soumya Ray, Nicholas Danks, and contributors. seminr-ts is licensed under the GNU General Public License v3.0, matching seminr.
Development
Development uses Bun exclusively (tests, scripts, package
management); the TypeScript compiler provides typechecking and the published
dist/ output.
The source is TS-native: relative imports use .ts extensions (Bun runs them
directly), and tsc's rewriteRelativeImportExtensions rewrites them to .js
only in the emitted dist/. A hygiene test
(tests/ts-native-imports.test.ts) keeps .js module specifiers out of the
source; the only .js strings in the repo are runtime references to compiled
output (the dist worker URL and the browser demo's bundle routes).
bun install
bun test # includes building dist/ for the demo tests
bun run typecheck # library
bun run typecheck:demos # demos (builds dist/ first)
bun run build
bun run smoke:pack # tarball install smoke test