
Stata C Plugins
- 57 installs
- 274 repo stars
- Updated April 10, 2026
- dylantmoore/stata-skill
Build high-performance C/C++ plugins for Stata using the stplugin.h SDK, including cross-platform compilation and porting Python/R packages to Stata.
About
Guides the full lifecycle of writing C/C++ plugins for Stata: SDK setup, data flow, memory safety, .ado wrappers, cross-platform builds, and net-install distribution. A developer uses it to accelerate Stata commands in C or translate an existing Python/R statistical package into a Stata plugin.
- Covers 1-based SF_vdata/SF_vstore indexing, preserve/merge .ado patterns, and gtools-style cross-platform plugin loading
- Includes translation workflow to wrap existing C++ backends and cross-compile for macOS, Linux, and Windows
Stata C Plugins by the numbers
- 57 all-time installs (skills.sh)
- Ranked #908 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dylantmoore/stata-skill --skill stata-c-pluginsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 57 |
|---|---|
| repo stars | ★ 274 |
| Last updated | April 10, 2026 |
| Repository | dylantmoore/stata-skill ↗ |
What it does
Build high-performance C/C++ plugins for Stata using the stplugin.h SDK, including cross-platform compilation and porting Python/R packages to Stata.
Files
Stata C/C++ Plugin Development
Build high-performance C/C++ plugins for Stata. This skill covers the full lifecycle from SDK setup through cross-platform distribution, based on real experience building production Stata plugins for statistical imputation, random forests, string matching, and causal inference.
This skill assumes macOS (Apple Silicon or Intel) as the development platform. Build commands, cross-compilation workflows, and Docker instructions are all Mac-oriented. The plugins themselves target all four platforms (macOS ARM64, macOS x86_64, Linux x86_64, Windows x86_64), but the development environment is macOS. If you need to develop on Linux or Windows natively, adapt the compilation and Docker sections accordingly.
How to Approach Every Task
Before writing any code, enter plan mode. A good plan covers:
1. Complete inventory — every feature, option, and component to build (for translation: exhaustive catalog of the source package's API) 2. Architecture decisions — wrap C++ backend vs. write C from scratch vs. pure Stata 3. Relevant reference files — identify up front which of this skill's reference files contain info you'll need, and cite them explicitly in the plan steps so they get loaded at the right time:
references/translation_workflow.md— full translation workflow, test repurposing, fidelity auditreferences/testing_strategy.md— test layers, reference data generation, Layer 0 (repurpose original tests)references/performance_patterns.md— pthreads, XorShift RNG, quickselect, pre-sorted indicesreferences/packaging_and_help.md— .toc/.pkg/.sthlp templates, build scriptsreferences/cpp_plugins.md— C++ wrapping, extern "C", exception safety, compilation
4. Phase-by-phase steps with dependencies between them 5. For each step: what gets built, what tests get written, and that the review loop runs before proceeding 6. For translation projects: a final fidelity audit as the last step (see translation_workflow.md)
Implement sequentially across components, in parallel within each component. Once an interface is defined, dispatch independent sub-tasks as parallel subagents (e.g., C plugin implementation, .ado wrapper, and test suite can run simultaneously). Merge their work, run the full test suite, then proceed to the review loop before moving to the next component.
Run the review loop after every component:
- Default: dispatch 2-3 review agents in parallel, ideally from different models (e.g., Claude + GPT + Gemini) for diversity of perspective. Use whatever multi-model tools are available in your environment.
- If only one model is available: dispatch 2-3 agents with different review focuses (correctness, completeness, architecture). Different prompts approximate the diversity of different models.
- Each agent reviews the diff, test results, and requirements — instruction: "List any gaps, bugs, or issues. Say LGTM if everything looks correct."
- Fix all issues raised, re-dispatch, loop until all agents say LGTM. Then proceed.
Wrap First, Write From Scratch Second
When translating a package, always check for an existing C/C++ backend before writing any algorithm code. Many R packages have C++ in src/. Many Python packages have Cython or vendored C/C++ libraries. Standalone C++ libraries exist for string matching, linear algebra, tree algorithms, and more.
If a C++ implementation exists, wrap it. Do not reimplement the algorithm in C. Wrapping gives you identical output (same code path), production-grade performance, and a fraction of the code. The plugin is just a thin extern "C" glue layer between Stata's SDK and the library's API. Binary size is irrelevant — statically link everything (-static-libstdc++ -static-libgcc) and ship whatever size the binary turns out to be, even 10-15 MB on Windows. Users don't care about plugin file size; they care about correct results.
See references/cpp_plugins.md for the full pattern and references/translation_workflow.md for the workflow. Working examples of this approach (wrapping C++ backends, multi-plugin dispatching, save/load for scoring on new data) can be found in the repos listed in the project CLAUDE.md under "Example Applications."
For translation projects, also: repurpose the original package's test suite and data (see references/testing_strategy.md Layer 0), write additional Stata-specific tests, and end the plan with a multi-agent fidelity audit. See references/translation_workflow.md for the complete workflow.
The Plugin SDK
Download stplugin.h and stplugin.c from: https://www.stata.com/plugins/
These two files define the interface between your C code and Stata:
| Function/Macro | Purpose |
|---|---|
SF_vdata(var, obs, &val) | Read variable value (1-indexed!) |
SF_vstore(var, obs, val) | Write variable value (1-indexed!) |
SF_nobs() | Number of observations in current dataset |
SF_nvar() | Number of variables in the entire dataset (not just plugin call) |
SF_is_missing(val) | Check for Stata missing value (.) |
SV_missval | The missing value constant |
SF_display(msg) | Print informational text in Stata |
SF_error(msg) | Print red error text in Stata |
Indexing is 1-based. Both variable indices and observation indices start at 1, not 0. Off-by-one errors here are silent and catastrophic — you read the wrong variable's data with no warning.
Memory Safety
A crash in your plugin kills the entire Stata session. No save prompt, no recovery. The user loses all unsaved work. This is the single most important thing to internalize.
- Check every
malloc()/calloc()return forNULL - Validate
argcbefore accessingargv[] - Build with
-fsanitize=addressduring development - Test on small data first, scale up gradually
- Pre-allocate all memory upfront in
stata_call(), free at the end
The stata_call() Entry Point
Every plugin implements one function. Plugins can also be written in C++ — the entry point just needs extern "C" linkage so Stata can find it; everything else can be full C++. The obvious case for C++ is when existing C++ code is available to wrap (e.g., an R package's src/ directory). C++ also helps when you need complex data structures or threading via std::thread. For practical C++ guidance — the extern "C" pattern, exception safety, compilation commands, wrapping libraries — see references/cpp_plugins.md. The rest of this file focuses on C because it's the simpler default.
#include "stplugin.h"
// For C++ plugins, wrap the entry point with extern "C":
// extern "C" {
// STDLL stata_call(int argc, char *argv[]) { ... }
// }
STDLL stata_call(int argc, char *argv[]) {
// 0. Validate arguments BEFORE accessing argv[]
if (argc < 3) {
SF_error("myplugin requires 3 arguments: n_train n_test seed\n");
return 198; // Stata's "syntax error" code
}
// 1. Parse arguments (all strings — use atoi/atof)
int n_train = atoi(argv[0]);
int n_test = atoi(argv[1]);
int seed = atoi(argv[2]);
// 2. Get dimensions
ST_int nobs = SF_nobs();
// CAUTION: SF_nvar() returns ALL variables in the dataset, not just
// the ones passed to `plugin call`. If the .ado creates tempvars
// (touse, merge_id, etc.) the count will be higher than expected.
// Pass the variable count via argv instead of relying on SF_nvar().
int p = atoi(argv[3]); // safer: pass feature count explicitly
// 3. Allocate memory
double *X = calloc(nobs * p, sizeof(double));
double *y = calloc(nobs, sizeof(double));
double *pred = calloc(nobs, sizeof(double));
if (!X || !y || !pred) {
SF_error("myplugin: out of memory\n");
if (X) free(X); if (y) free(y); if (pred) free(pred);
return 909;
}
// 4. Read data from Stata (1-indexed!)
ST_double val;
for (ST_int obs = 1; obs <= nobs; obs++) {
SF_vdata(1, obs, &val); // var 1 = depvar
y[obs-1] = val;
for (int j = 0; j < p; j++) {
SF_vdata(j + 2, obs, &val); // vars 2..nvars-1 = features
X[(obs-1) * p + j] = val;
}
}
// 5. Run your algorithm
int rc = my_algorithm(X, y, pred, n_train, n_test, p, seed);
if (rc != 0) {
SF_error("myplugin: algorithm failed\n");
free(X); free(y); free(pred);
return 909;
}
// 6. Write results back to Stata
for (ST_int obs = 1; obs <= nobs; obs++) {
SF_vstore(nvars, obs, pred[obs-1]); // last var = output
}
free(X); free(y); free(pred);
return 0; // 0 = success
}Return Codes
0— success198— syntax error (bad arguments)909— insufficient memory601— file not found- Any non-zero triggers a Stata error
The .ado Wrapper Pattern
Users never call plugin call directly. An .ado file provides the Stata-native interface.
The Preserve/Merge Pattern
This is the core pattern for plugins that operate on a subset of data:
program define mycommand, rclass
syntax varlist(min=2) [if] [in], GENerate(name) [SEED(integer 12345) REPlace]
gettoken depvar indepvars : varlist
if "`replace'" != "" {
capture drop `generate'
}
confirm new variable `generate'
// Mark sample: novarlist ALLOWS missing depvar (critical for imputation)
marksample touse, novarlist
markout `touse' `indepvars' // but DO exclude missing predictors
// Stable merge key — create BEFORE any sorting or subsetting
tempvar merge_id
quietly gen long `merge_id' = _n
// Count subsets
quietly count if `touse' & !missing(`depvar')
local n_train = r(N)
quietly count if `touse' & missing(`depvar')
local n_test = r(N)
// Create output variable (all missing initially)
quietly gen double `generate' = .
// Preserve, subset, call plugin
preserve
quietly keep if `touse'
// Sort if plugin requires it (donors first, test second)
tempvar sort_order
quietly gen `sort_order' = missing(`depvar')
quietly sort `sort_order'
// Call plugin
plugin call myplugin `depvar' `indepvars' `generate', ///
`n_train' `n_test' `seed'
// Save results and restore
tempfile results
quietly keep `merge_id' `generate'
quietly save `results'
restore
// Merge predictions back (update replaces missing with non-missing)
quietly merge 1:1 `merge_id' using `results', nogenerate update
endWhy `update` works: The generate variable is all-missing before preserve. After restore, it's still all-missing. The update option replaces missing values with non-missing ones from the merge file. The replace option is handled earlier via capture drop, so by merge time the variable is always freshly created.
Plugin Sorting Contract
CRITICAL: Some plugins expect data sorted a specific way (training rows first, test rows second). Others handle missing data internally. Sorting mismatches are among the most dangerous bugs — the plugin silently reads the wrong data, producing garbage output with no error message. A mismatched sort order can drop prediction quality dramatically (e.g., correlation going from 0.99 to 0.38) because the plugin treats test observations as training data and vice versa.
- If the plugin checks
SF_is_missing()internally: do NOT sort in the .ado wrapper - If the plugin expects
n_traincontiguous rows thenn_testrows: sort bymissing(depvar)before calling
Document which pattern your plugin uses.
Plugin Loading (Cross-Platform)
Use the gtools-style OS detection pattern. This detects the OS via c(os) and constructs a bare filename. The bare filename is resolved via Stata's adopath, which is reliable across all platforms.
/* ---- Load plugin (gtools-style: detect OS, bare filename) ---- */
if ( inlist("`c(os)'", "MacOSX") | strpos("`c(machine_type)'", "Mac") ) local c_os_ macosx
else local c_os_: di lower("`c(os)'")
cap program drop myplugin
program myplugin, plugin using("myplugin_`c_os_'.plugin")This resolves to myplugin_macosx.plugin, myplugin_windows.plugin, or myplugin_unix.plugin depending on platform.
WARNING — DO NOT use `findfile` + absolute paths. The following pattern is BROKEN on Windows and must never be used:
* BROKEN — DO NOT USE
capture findfile myplugin.plugin
capture program myplugin, plugin using("`r(fn)'")findfile returns an absolute path (e.g., C:\ado\plus\m\myplugin.plugin). On Windows, Stata's LoadLibrary call fails when given certain absolute paths via using(). The gtools-style pattern avoids this by passing a bare filename (no path), which Stata resolves via the adopath — exactly how gtools, ftools, and other major packages work.
Similarly, do not use a nested if/else cascade trying each platform-arch suffix. This was the old pattern in several packages and fails for the same reason if findfile is involved, plus it's fragile and verbose.
Plugin file naming: pluginname_os.plugin where os is one of macosx, unix, windows. Examples: qrf_plugin_macosx.plugin, grf_plugin_windows.plugin.
Note: clear all wipes loaded plugin definitions. If a test script starts with clear all, all program ... plugin definitions are gone. Reload them.
Cross-Platform Compilation
Build for three platforms (ARM Macs run x86_64 via Rosetta, so one macOS binary suffices). Install the Windows cross-compiler first: brew install mingw-w64.
| Target OS | Output name suffix | Compiler | -D flag | Link flag | pthreads |
|---|---|---|---|---|---|
| macOS (ARM64) | _macosx | gcc -arch arm64 | -DSYSTEM=APPLEMAC | -bundle | -pthread |
| Linux (x86_64) | _unix | gcc | -DSYSTEM=OPUNIX | -shared | -pthread |
| Windows (x86_64) | _windows | x86_64-w64-mingw32-gcc | -DSYSTEM=STWIN32 | -shared | -lwinpthread |
All platforms: -O3 -fPIC for release, add -g -fsanitize=address for development.
For C++ plugins: use g++ instead of gcc. Add -std=c++ at the version the library requires (check its docs — C++11, C++14, and C++17 are all common). Header-only C++ libraries can be vendored into c_source/ and included with -I.. Always use -static-libstdc++ -static-libgcc on Windows and Linux.
Naming convention: pluginname_os.plugin (e.g., qrf_plugin_macosx.plugin, grf_plugin_windows.plugin). The os suffix must match what the gtools-style loader produces: macosx, unix, or windows.
macOS note: use -bundle, NOT -shared. This is a common mistake.
Linux from macOS (Docker Required)
There is no native Linux cross-compiler on macOS. Use Docker via Colima (brew install colima docker, then colima start). Build with a one-liner:
docker run --rm --platform linux/amd64 -v "$(pwd):/build" -w /build ubuntu:18.04 \
bash -c "apt-get update -qq && apt-get install -y -qq g++ gcc make > /dev/null 2>&1 && make linux"glibc compatibility: Build on Ubuntu 18.04 for maximum compatibility (requires only GLIBC 2.14, works on any Linux from ~2012+). Building on Ubuntu 22.04+ requires GLIBC 2.34, which excludes RHEL 8, Ubuntu 20.04, and many HPC environments.
Performance Optimization
See references/performance_patterns.md for detailed code examples of:
1. Pre-sorted feature indices — Sort feature values once, scan linearly at each tree node. O(n) per split instead of O(n log n). 2. Precomputed distance norms — Exploit ||a-b||^2 = ||a||^2 + ||b||^2 - 2a'b for KNN. 3. Quickselect — O(n) partial sort for finding k-th nearest neighbor. 4. Parallel ensemble training (pthreads) — Train multiple models concurrently. Each thread gets its own data copy and RNG state. Never call Stata SDK functions (`SF_vdata`, `SF_vstore`, `SF_display`) from worker threads — read all data on the main thread first, dispatch computation to workers, write results back on the main thread after joining. 5. XorShift RNG — C plugins cannot access Stata's internal RNG (`runiform()`). XorShift128+ is fast, statistically sound, and thread-safe (each thread gets its own state). Seed from `argv[]` for reproducibility. 6. Dense arrays for trees* — Flat node arrays instead of linked lists for cache locality.
Debugging
Debugging is hard because you can't attach a debugger to Stata's plugin host.
Strategies
1. Printf via SF_display():
char buf[256];
snprintf(buf, sizeof(buf), "Debug: n=%d, p=%d\n", n, p);
SF_display(buf);2. Write diagnostic files:
FILE *f = fopen("plugin_debug.log", "w");
fprintf(f, "value at [%d][%d] = %f\n", i, j, val);
fclose(f);3. Test standalone first. Write a main() that reads CSV and calls your algorithm. Debug with normal tools (gdb, valgrind, sanitizers). Then adapt for the plugin interface.
4. Build with sanitizers during development: -g -fsanitize=address
5. Check SF_vdata() return values. It returns RC (0=success). Non-zero means invalid obs/var index.
Common Failure Modes
| Symptom | Likely Cause |
|---|---|
| Stata crashes silently | Segfault: buffer overflow, bad argv access, NULL deref |
| Plugin returns all missing | Wrong variable count, wrong obs indexing, plugin not loaded |
| Results are garbage | Sorting mismatch, 0-vs-1 indexing error, unnormalized inputs |
| "plugin not found" | Wrong filename, clear all wiped definition, wrong platform |
| Works on Mac, fails on Linux | Integer size difference, use int32_t/int64_t from <stdint.h> |
Packaging and Distribution
Use platform-specific `.pkg` files so users only download the binary for their OS. Stata's net install has no conditional logic, so the way to avoid shipping all 4 binaries to every user is to offer separate packages per platform. All packages install the same .ado and .sthlp files — only the .plugin binary differs.
mypackage/
├── stata.toc # lists all package variants
├── mypackage.pkg # all platforms (for users who don't care)
├── mypackage_mac.pkg # macOS only
├── mypackage_linux.pkg # Linux only
├── mypackage_win.pkg # Windows only
├── mycommand.sthlp # overview help file (short name!)
├── mycommand.ado # user-facing command
├── myplugin_macosx.plugin
├── myplugin_unix.plugin
├── myplugin_windows.plugin
└── c_source/ # NOT distributed, for building
├── build.py
├── stplugin.c
├── stplugin.h
└── algorithm.cUsers install their platform's package:
* macOS
net install mypackage_mac, from("https://raw.githubusercontent.com/user/repo/main") replace
* Linux
net install mypackage_linux, from("https://raw.githubusercontent.com/user/repo/main") replace
* Windows
net install mypackage_win, from("https://raw.githubusercontent.com/user/repo/main") replaceAll platform binaries ship via the all-platform .pkg, or users can install platform-specific packages. Stata loads only the matching plugin at runtime via gtools-style OS detection. Windows C++ binaries can be 10-15MB due to static linking, which is normal.
See references/packaging_and_help.md for .toc, .pkg, .sthlp templates and SMCL formatting.
Common Pitfalls
1. Sorting destroys merge keys. If you sort inside preserve/restore, the merge_id linkage breaks. Always create merge_id BEFORE preserve.
2. 1-indexed everything. SF_vdata(var, obs, &val) — both var and obs start at 1. Off-by-one errors are silent.
3. `marksample` excludes missing by default. For imputation (where missing depvar IS the point), use marksample touse, novarlist.
4. macOS `c(os)` returns "MacOSX". Use the gtools pattern: inlist("c(os)'", "MacOSX") | strpos("c(machine_type)'", "Mac") to detect Mac. For other platforms, lower(c(os)) gives "windows" or "unix".
5. argv[] has no bounds checking. Accessing argv[3] when argc == 2 is a segfault. Always check argc first.
6. `clear all` wipes plugins. Reload plugin definitions after clear all in test scripts.
7. Only the first `program define` in a .ado file is auto-discovered. Subprograms need their own .ado files or explicit run to load.
8. Normalize inputs when the algorithm requires it (neural networks, gradient-based methods, distance-based methods like KNN). Scale to mean=0, sd=1 in the .ado wrapper, denormalize predictions after. The plugin should receive clean, normalized data — let the .ado handle the scaling.
9. pthreads on Windows needs `-lwinpthread`. Use conditional linker flags.
10. Memory errors crash Stata with no recovery. Pre-allocate everything, check every allocation, build with sanitizers during development.
11. glibc version mismatch. Building Linux plugins on a modern distro produces binaries that won't load on older systems. Use Ubuntu 18.04 in Docker for maximum compatibility.
12. `SF_nvar()` returns total dataset variables. It counts ALL variables in the dataset, not just the ones in the plugin call varlist. If the .ado creates tempvars (touse, merge_id, sort keys), the count will be higher than expected. Never use SF_nvar() to validate argument counts — pass the expected count via argv instead.
13. `findfile` + absolute paths breaks on Windows. findfile returns an absolute path that Stata's LoadLibrary can't resolve on Windows. Use the gtools-style OS detection pattern instead (see Plugin Loading section above) — it constructs a bare filename that Stata resolves via the adopath.
Naming Conventions
- Use
method()notmodel()for method selection options - Use
generate()(abbreviationgen()) for output variable naming - Use
replaceas a flag option, notreplace() - Plugin files:
algorithm_plugin_os.pluginwhere os ismacosx,unix, orwindows - .ado files: lowercase, underscores for multi-word
- Stata option convention: options lowercase, abbreviations capitalized (
GENerate,MAXDepth) - Target Stata 14.0+ (
version 14.0) for plugin support - Help files use the short command name, not the repo name. If the repo is called
mypackage_stata, the overview help file should still bemypackage.sthlp(sohelp mypackageworks). Don't append "stata" to help file or command names — the user is already in Stata.
C++ Plugins for Stata
Practical guidance for building Stata plugins in C++. Wrapping an existing C++ library is the most common use case. If a C++ implementation of your algorithm exists, prefer wrapping it — you get identical output, the same performance, and far less code to write and maintain. This file covers the patterns you need.
When to Use C++
- An existing C++ implementation exists. This is the most common case and should always be your first check. If you're translating an R package that has a C++ backend in
src/, or a standalone C++ library exists for the algorithm, wrap it. You get identical output (same code path), same performance, and a fraction of the code. Prefer wrapping over reimplementing. Only reimplement from scratch if no C++ backend exists, the backend has unmanageable dependencies, or its API is too poorly documented to wrap efficiently. - A standalone C++ library does what you need. RapidFuzz for string matching, Eigen for linear algebra, nlohmann/json for config parsing, etc. Header-only libraries are especially easy — vendor the headers and add
-I. - Complex data structures. Trees, graphs, hash maps, priority queues —
std::map,std::unordered_map,std::priority_queueare battle-tested. - Threading with `std::thread`/`std::async`. Simpler than raw pthreads for many use cases.
When C Is Fine
Use C only when no C++ backend or library exists and the algorithm is simple arrays-and-loops logic. The C approach in the main SKILL.md covers this case.
The extern "C" Pattern
Stata loads plugins by looking for a function named stata_call with C linkage. C++ name-mangles all functions by default, so Stata can't find them. The fix is extern "C".
// wrapper.cpp
#include "stplugin.h"
#include <vector>
#include <stdexcept>
#include "my_algorithm.h" // your C++ code or vendored library
extern "C" {
STDLL stata_call(int argc, char *argv[]) {
try {
if (argc < 2) {
SF_error("myplugin requires 2 arguments\n");
return 198;
}
int n = atoi(argv[0]);
int seed = atoi(argv[1]);
ST_int nobs = SF_nobs();
ST_int nvars = SF_nvar();
// Use C++ containers instead of malloc
std::vector<double> X(nobs * (nvars - 1));
std::vector<double> results(nobs);
// Read from Stata (1-indexed)
ST_double val;
for (ST_int obs = 1; obs <= nobs; obs++) {
for (int j = 0; j < nvars - 1; j++) {
SF_vdata(j + 1, obs, &val);
X[(obs - 1) * (nvars - 1) + j] = val;
}
}
// Call C++ library
MyAlgorithm algo(n, seed);
algo.fit(X.data(), nobs, nvars - 1);
algo.predict(X.data(), results.data(), nobs);
// Write back to Stata
for (ST_int obs = 1; obs <= nobs; obs++) {
SF_vstore(nvars, obs, results[obs - 1]);
}
return 0;
} catch (const std::exception &e) {
char buf[512];
snprintf(buf, sizeof(buf), "myplugin error: %s\n", e.what());
SF_error(buf);
return 909;
} catch (...) {
SF_error("myplugin: unknown error\n");
return 909;
}
}
} // extern "C"Key points:
- Only
stata_callneedsextern "C". All other functions can be normal C++. - The
extern "C"block can wrap just the one function, or you can useextern "C" STDLL stata_call(...)on the declaration directly. - Everything inside
stata_callcan use C++ freely: templates, classes, STL, exceptions (as long as they're caught).
Handling stplugin.c
stplugin.c is C code. You have two options:
Option 1 (recommended): Compile separately.
gcc -c -O3 -fPIC -DSYSTEM=APPLEMAC stplugin.c -o stplugin.o
g++ -O3 -std=c++17 -fPIC -DSYSTEM=APPLEMAC -bundle -o plugin.plugin wrapper.cpp stplugin.oOption 2: Include with `extern "C"` trick.
// At the top of wrapper.cpp, before any C++ headers
extern "C" {
#include "stplugin.c"
}Option 1 is cleaner and avoids any issues with C constructs that aren't valid C++. Option 2 works but can break if stplugin.c uses anything C++-incompatible.
Exception Safety
Uncaught exceptions escaping `stata_call` crash Stata instantly. No error message, no recovery, the user loses all unsaved work. This is the single most important rule for C++ plugins.
Always wrap the entire body of stata_call in try/catch:
extern "C" {
STDLL stata_call(int argc, char *argv[]) {
try {
// ALL your code here
return 0;
} catch (const std::bad_alloc &e) {
SF_error("myplugin: out of memory\n");
return 909;
} catch (const std::exception &e) {
char buf[512];
snprintf(buf, sizeof(buf), "myplugin: %s\n", e.what());
SF_error(buf);
return 909;
} catch (...) {
SF_error("myplugin: unknown internal error\n");
return 909;
}
}
}Catch std::bad_alloc separately so you can return Stata's memory error code (909). Catch std::exception for anything with a message. Catch ... as a last resort for non-standard exceptions.
Compilation
Use g++ instead of gcc for the C++ files. stplugin.c must be compiled as C. Use whatever -std=c++ version the library requires (C++11, C++14, C++17 are all common — check the library's docs). The examples below use C++17.
darwin-arm64 (Apple Silicon Mac)
gcc -c -O3 -fPIC -DSYSTEM=APPLEMAC -arch arm64 stplugin.c -o stplugin.o
g++ -O3 -std=c++17 -fPIC -DSYSTEM=APPLEMAC -arch arm64 -bundle \
-o myplugin.darwin-arm64.plugin wrapper.cpp stplugin.o -lmdarwin-x86_64 (Intel Mac)
gcc -c -O3 -fPIC -DSYSTEM=APPLEMAC -target x86_64-apple-macos10.12 stplugin.c -o stplugin.o
g++ -O3 -std=c++17 -fPIC -DSYSTEM=APPLEMAC -target x86_64-apple-macos10.12 -bundle \
-o myplugin.darwin-x86_64.plugin wrapper.cpp stplugin.o -lmlinux-x86_64
gcc -c -O3 -fPIC -DSYSTEM=OPUNIX stplugin.c -o stplugin.o
g++ -O3 -std=c++17 -fPIC -DSYSTEM=OPUNIX -shared \
-static-libstdc++ -static-libgcc \
-o myplugin.linux-x86_64.plugin wrapper.cpp stplugin.o -lmStatic linking (-static-libstdc++ -static-libgcc) is important for Linux so end users don't need a compatible libstdc++ installed.
macOS users: There is no native Linux cross-compiler on macOS. Run these commands inside a Docker container (see the Docker approach in the main SKILL.md Cross-Platform Compilation section).
windows-x86_64 (cross-compile from Mac/Linux)
Install the cross-compiler first: brew install mingw-w64.
x86_64-w64-mingw32-gcc -c -O3 -DSYSTEM=STWIN32 stplugin.c -o stplugin.o
x86_64-w64-mingw32-g++ -O3 -std=c++17 -DSYSTEM=STWIN32 -shared \
-static-libstdc++ -static-libgcc \
-o myplugin.windows-x86_64.plugin wrapper.cpp stplugin.o -lmNote the Windows flags: -static-libstdc++ -static-libgcc statically links the C++ runtime so users don't need to install anything extra.
With header-only libraries (e.g., Eigen)
Just add the include path:
g++ -O3 -std=c++17 -fPIC -DSYSTEM=APPLEMAC -arch arm64 -bundle \
-I./eigen -o myplugin.darwin-arm64.plugin wrapper.cpp stplugin.o -lmNo linking step needed for header-only libraries.
Shipping
No difference from C plugins. The output is a single .plugin binary per platform, same naming convention (pluginname.platform.plugin), same .ado wrapper, same .pkg distribution.
- Header-only C++ libraries are compiled into the binary. No runtime dependency.
- Always use `-static-libstdc++ -static-libgcc` on Windows and Linux. This statically links the C++ runtime so end users don't need anything installed. Do this for every C++ plugin, no exceptions.
- Users cannot tell whether a plugin was written in C or C++. The
.pluginfile is opaque. - Same cascade loading pattern, same
net installdistribution. - Binary size is not a concern. Expected sizes: macOS ~100-300K, Linux ~1-2MB, Windows ~2-15MB (fully static). These are normal. Users care about correct results, not plugin file size. Ship all platforms with all dependencies statically linked.
Wrapping an Existing C++ Library
This is the primary use case for C++ plugins and should be your default approach. If a C++ implementation exists — whether from an R package's src/ directory, a standalone library, or a header-only library — wrap it. Do not reimplement the algorithm in C or C++ from scratch.
Finding C++ Backends
- R packages: Check
src/on GitHub or in the CRAN source tarball. Many R packages use Rcpp and have their algorithms in.cppfiles. Look for R packages with asrc/directory containing C++ code — these are wrapping candidates. - Python packages: Look for Cython (
.pyxfiles), C extensions, or vendored C/C++ code. Checksetup.pyorpyproject.tomlfor compiled extension definitions. - Standalone C++ libraries: Search GitHub for
<algorithm-name> cppor<algorithm-name> header-only. Some algorithms have reference implementations as standalone C++ projects.
Steps
1. Clone or vendor the source into your project (e.g., c_plugin/lib/). 2. Identify the library's API. Find the main classes/functions: typically a constructor, a fit()/train() method, and a predict() method. 3. Write a thin `stata_call` wrapper that:
- Reads data from Stata with
SF_vdata() - Converts to whatever format the library expects (arrays, Eigen matrices, custom structs)
- Calls the library's training/prediction functions
- Writes results back with
SF_vstore()
4. The `.ado` wrapper handles all Stata syntax. The C++ just does computation. Users interact with Stata syntax, not the plugin directly.
Example: Wrapping a library that uses Eigen
// wrapper.cpp
#include "stplugin.h"
#include <Eigen/Dense>
#include "library_api.h"
extern "C" {
STDLL stata_call(int argc, char *argv[]) {
try {
ST_int nobs = SF_nobs();
int p = SF_nvar() - 1; // last var is output
// Read into Eigen matrix
Eigen::MatrixXd X(nobs, p);
ST_double val;
for (ST_int i = 0; i < nobs; i++) {
for (int j = 0; j < p; j++) {
SF_vdata(j + 1, i + 1, &val); // 1-indexed!
X(i, j) = val;
}
}
// Call library
Eigen::VectorXd result = library_compute(X);
// Write back
int out_var = SF_nvar();
for (ST_int i = 0; i < nobs; i++) {
SF_vstore(out_var, i + 1, result(i));
}
return 0;
} catch (const std::exception &e) {
char buf[512];
snprintf(buf, sizeof(buf), "plugin error: %s\n", e.what());
SF_error(buf);
return 909;
} catch (...) {
SF_error("plugin: unknown error\n");
return 909;
}
}
}Using C++ Standard Library Features
When writing a plugin from scratch in C++ (rather than wrapping a library), the standard library gives you safer, simpler code than C equivalents:
- `std::vector<double>` instead of
malloc/free-- automatic cleanup even on exceptions, bounds checking with.at(), no manual size tracking. - `std::sort` instead of
qsort-- type-safe, usually faster (inlines the comparator). - `std::thread`/`std::async` for parallelism -- simpler than pthreads for independent work units. Important: never call Stata SDK functions (
SF_vdata,SF_vstore,SF_display, etc.) from worker threads. Read all data on the main thread, dispatch computation to workers, then write results back on the main thread. Join all threads before returning fromstata_call. - `std::unordered_map` for hash tables -- no need to write your own.
- `std::string` for string handling -- no buffer overflow risk from
sprintf.
But keep it simple. This is a Stata plugin, not a framework. Use the standard library where it makes code safer or shorter, not for the sake of being "modern C++."
Caveats
- Template errors produce unreadable compiler output. This is especially painful when using Eigen or other template-heavy libraries. Focus on the first error and fix it — later errors are often cascading noise.
- Always use `-static-libstdc++ -static-libgcc` on Windows and Linux so users don't need a compatible C++ runtime. This is mandatory, not optional.
- Debugging is the same challenge as C plugins. You still can't attach a debugger to Stata's plugin host. Use
SF_display(), log files, and standalone test harnesses (see Debugging section in main SKILL.md). - ABI compatibility matters. If you compile the library with one compiler version and the wrapper with another, you can get silent corruption. Use the same compiler for everything.
Stata Package Structure and Distribution
Required Files for net install
stata.toc (Table of Contents)
List all package variants — one all-platforms package plus one per OS:
v 3
d packagename - Short description of the package
d Author Name, Institution
d Distribution-Date: YYYYMMDD
p packagename All platforms (macOS, Linux, Windows)
p packagename_mac macOS only (ARM64 + Intel)
p packagename_linux Linux only (x86_64)
p packagename_win Windows only (x86_64)- Line 1:
v 3(version) dlines: description, author, datep packagenamelines: each references a.pkgfile. Text after the name is a description shown to users.
Platform-Specific .pkg Files
Create one .pkg per platform. All packages install the same .ado and .sthlp files — only the .plugin binary differs. This way users download only the binary for their OS.
packagename_mac.pkg (macOS — includes both ARM64 and Intel):
v 3
d packagename: One-line description
d
d Author Name, Institution
d email@example.com
d
d Distribution-Date: YYYYMMDD
d
f mycommand.ado
f mycommand.sthlp
f mycommand_sub.ado
f mycommand_sub.sthlp
f myplugin_macosx.pluginpackagename_linux.pkg (Linux x86_64):
v 3
d packagename: One-line description
d
d Author Name, Institution
d email@example.com
d
d Distribution-Date: YYYYMMDD
d
f mycommand.ado
f mycommand.sthlp
f mycommand_sub.ado
f mycommand_sub.sthlp
f myplugin_unix.pluginpackagename_win.pkg (Windows x86_64):
v 3
d packagename: One-line description
d
d Author Name, Institution
d email@example.com
d
d Distribution-Date: YYYYMMDD
d
f mycommand.ado
f mycommand.sthlp
f mycommand_sub.ado
f mycommand_sub.sthlp
f myplugin_windows.pluginpackagename.pkg (all platforms — for users who don't care about download size):
v 3
d packagename: One-line description
d
d Author Name, Institution
d email@example.com
d
d Distribution-Date: YYYYMMDD
d
f mycommand.ado
f mycommand.sthlp
f mycommand_sub.ado
f mycommand_sub.sthlp
f myplugin_macosx.plugin
f myplugin_unix.plugin
f myplugin_windows.pluginflines list every file to install- Files install to the user's PLUS ado directory in a letter-subdirectory (e.g.,
plus/g/) - Only list
.pluginfiles that actually exist — listing a nonexistent file fails the install - Stata loads the right plugin at runtime via gtools-style OS detection
Installation Commands
* macOS
net install packagename_mac, from("https://raw.githubusercontent.com/user/repo/main") replace
* Linux
net install packagename_linux, from("https://raw.githubusercontent.com/user/repo/main") replace
* Windows
net install packagename_win, from("https://raw.githubusercontent.com/user/repo/main") replace
* All platforms (larger download)
net install packagename, from("https://raw.githubusercontent.com/user/repo/main") replaceThe from() URL must point to a directory containing stata.toc. The repo must be public — private repos return 404 from raw.githubusercontent.com.
Plugin Loading (gtools-style)
Use the gtools-style OS detection pattern in every .ado that calls the plugin. This detects the OS and constructs a bare filename that Stata resolves via the adopath:
/* ---- Load plugin (gtools-style: detect OS, bare filename) ---- */
if ( inlist("`c(os)'", "MacOSX") | strpos("`c(machine_type)'", "Mac") ) local c_os_ macosx
else local c_os_: di lower("`c(os)'")
cap program drop myplugin
program myplugin, plugin using("myplugin_`c_os_'.plugin")WARNING — DO NOT use `findfile` + absolute paths. findfile returns absolute paths that fail with Stata's LoadLibrary on Windows. The gtools-style bare filename pattern is proven reliable across all platforms.
Help File Naming
Help files use the short command name, not the package/repo name. The repo might be called mypackage_stata for GitHub discoverability, but the help file should be mypackage.sthlp so that help mypackage works. Package name and help file name are independent: mypackage_stata_mac.pkg installs mypackage.sthlp.
For multi-command packages, create:
- One overview help file with the short package name (e.g.,
mypackage.sthlp) listing all subcommands - One help file per subcommand (e.g.,
mypackage_subcommand1.sthlp,mypackage_subcommand2.sthlp)
Help File (.sthlp) Template
{smcl}
{* *! version 1.0.0 DDmonYYYY}{...}
{viewerjumpto "Syntax" "commandname##syntax"}{...}
{viewerjumpto "Description" "commandname##description"}{...}
{viewerjumpto "Options" "commandname##options"}{...}
{viewerjumpto "Examples" "commandname##examples"}{...}
{viewerjumpto "Stored results" "commandname##results"}{...}
{title:Title}
{phang}
{bf:commandname} {hline 2} Short description of what the command does
{marker syntax}{...}
{title:Syntax}
{p 8 17 2}
{cmdab:commandname}
{depvar} {indepvars}
{ifin}
{cmd:,} {opt gen:erate(newvar)} [{it:options}]
{synoptset 25 tabbed}{...}
{synopthdr}
{synoptline}
{syntab:Required}
{synopt:{opt gen:erate(newvar)}}name of new variable to create{p_end}
{syntab:Method}
{synopt:{opt m:ethod(string)}}method name; default is {bf:default}{p_end}
{synopt:{opt q:uantile(#)}}target quantile; default is 0.5{p_end}
{syntab:Other}
{synopt:{opt seed(#)}}random seed; default is 12345{p_end}
{synopt:{opt replace}}replace existing variable{p_end}
{synoptline}
{marker description}{...}
{title:Description}
{pstd}
{cmd:commandname} does X based on Y.
{marker options}{...}
{title:Options}
{phang}
{opt method(string)} specifies the method. Options are:
{phang2}{bf:method1} - Description{p_end}
{phang2}{bf:method2} - Description{p_end}
{marker examples}{...}
{title:Examples}
{pstd}Setup{p_end}
{phang2}{cmd:. sysuse auto, clear}{p_end}
{pstd}Basic usage{p_end}
{phang2}{cmd:. commandname price mpg weight, gen(price_imputed)}{p_end}
{marker results}{...}
{title:Stored results}
{synoptset 20 tabbed}{...}
{p2col 5 20 24 2: Scalars}{p_end}
{synopt:{cmd:r(N)}}number of observations{p_end}
{synopt:{cmd:r(seed)}}random seed used{p_end}
{p2col 5 20 24 2: Macros}{p_end}
{synopt:{cmd:r(method)}}method used{p_end}SMCL Formatting Cheat Sheet
{txt}— plain text color{res}— result/highlight color{err}— error color{bf:text}— bold{it:text}— italic{cmd:text}— command formatting{hline 60}— horizontal rule{pstd}— standard paragraph indent{phang}— hanging indent{phang2}— double hanging indent{p_end}— end paragraph{browse "URL"}— clickable link{manhelp cmd SECTION}— link to Stata manual
Build Script Template
#!/usr/bin/env python3
"""Build Stata plugins for multiple platforms."""
import subprocess
import sys
PLATFORMS = {
'macosx': {
'cc': 'gcc',
'cflags': '-O3 -fPIC -DSYSTEM=APPLEMAC -arch arm64',
'ldflags': '-bundle -arch arm64',
},
'unix': {
'cc': 'gcc',
'cflags': '-O3 -fPIC -DSYSTEM=OPUNIX',
'ldflags': '-shared -static-libstdc++ -static-libgcc',
},
'windows': {
'cc': 'x86_64-w64-mingw32-gcc',
'cflags': '-O3 -DSYSTEM=STWIN32',
'ldflags': '-shared',
},
}
def build_plugin(name, sources, platforms=None):
"""Build a plugin for specified platforms."""
if platforms is None:
platforms = PLATFORMS.keys()
for platform in platforms:
cfg = PLATFORMS[platform]
output = f"{name}_{platform}.plugin"
cmd = (
f"{cfg['cc']} {cfg['cflags']} {cfg['ldflags']} "
f"-o {output} {' '.join(sources)}"
)
# Add pthreads
if platform == 'windows':
cmd += " -lwinpthread"
else:
cmd += " -pthread"
print(f"Building {output}...")
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode != 0:
print(f"FAILED: {result.stderr}")
sys.exit(1)
print(f" OK")
if __name__ == '__main__':
build_plugin(
'myplugin',
['algorithm.c', 'stplugin.c'],
)Makefile Template (C++ Plugin)
Alternative to the Python script. Compile stplugin.c as C separately per platform.
PLUGIN_NAME = myplugin
CPP_SOURCES = wrapper.cpp
CC = gcc
CXX = g++
TARGET_MACOSX = $(PLUGIN_NAME)_macosx.plugin
TARGET_UNIX = $(PLUGIN_NAME)_unix.plugin
TARGET_WINDOWS = $(PLUGIN_NAME)_windows.plugin
.PHONY: all macosx windows linux all-platforms clean
all: macosx
$(TARGET_MACOSX): $(CPP_SOURCES) stplugin.c
$(CC) -O3 -fPIC -DSYSTEM=APPLEMAC -arch arm64 -c stplugin.c -o stplugin.o
$(CXX) -std=c++14 -O3 -fPIC -DSYSTEM=APPLEMAC -arch arm64 -bundle \
-o $@ $(CPP_SOURCES) stplugin.o -lm
rm -f stplugin.o
$(TARGET_UNIX): $(CPP_SOURCES) stplugin.c
# Run inside Docker: docker run --rm --platform linux/amd64 -v "$$(pwd):/build" -w /build ubuntu:18.04 \
# bash -c "apt-get update -qq && apt-get install -y -qq g++ gcc make > /dev/null 2>&1 && make linux"
gcc -O3 -fPIC -DSYSTEM=OPUNIX -c stplugin.c -o stplugin.o
g++ -std=c++14 -O3 -fPIC -DSYSTEM=OPUNIX -shared -static-libstdc++ -static-libgcc \
-o $@ $(CPP_SOURCES) stplugin.o -lm
rm -f stplugin.o
$(TARGET_WINDOWS): $(CPP_SOURCES) stplugin.c
x86_64-w64-mingw32-gcc -O3 -DSYSTEM=STWIN32 -c stplugin.c -o stplugin.o
x86_64-w64-mingw32-g++ -std=c++14 -O3 -DSYSTEM=STWIN32 -shared \
-static-libstdc++ -static-libgcc -o $@ $(CPP_SOURCES) stplugin.o -lm
rm -f stplugin.o
macosx: $(TARGET_MACOSX)
linux: $(TARGET_UNIX)
windows: $(TARGET_WINDOWS)
all-platforms: macosx linux windows
clean:
rm -f *.plugin stplugin.oNaming Conventions
- Use
method()notmodel()for method selection options - Use
generate()(abbreviationgen()) for output variable naming - Use
replaceas a flag option, notreplace() - Plugin files:
algorithm_plugin_os.pluginwhere os ismacosx,unix, orwindows - .ado files: lowercase, underscores for multi-word commands
- Stata convention: options use lowercase, abbreviations capitalized (
GENerate,MAXDepth) - Target Stata 14.0+ for plugin support (
version 14.0) - Help files use the short command name, not the repo name. Repo
mypackage_stata→ help filemypackage.sthlp→ user typeshelp mypackage. Don't add "stata" to names the user types — they're already in Stata. - Commands also use the short name.
mypackage_subcommand, notmypackage_stata_subcommand. The package name (used fornet install) can include "stata" for GitHub discoverability, but commands and help files should not.
Useful Stata Idioms
quietly— suppresses output (use liberally in wrapper code)capture— suppresses errors and sets_rcnoisilyinsidecapture— re-enables display while still capturing rctempvar,tempfile— auto-cleaned temporary namespreserve/restore— save/restore dataset stategettoken depvar indepvars : varlist— split varlist into depvar + rest
C Plugin Performance Patterns
Each pattern below addresses a specific performance bottleneck. Use the one that matches your algorithm's hot path:
| Pattern | Use When | Speedup Source |
|---|---|---|
| Pre-sorted indices | Tree-based split search | O(n) scan instead of O(n log n) sort per split |
| Precomputed distance norms | KNN or distance-based methods | Avoids redundant norm computation |
| Quickselect | Finding k-th element (KNN neighbors, quantiles) | O(n) expected vs. O(n log n) full sort |
| Parallel training (pthreads) | Ensemble methods (random forests, bagging) | Trains multiple models simultaneously |
| XorShift RNG | Any stochastic algorithm | Fast, thread-safe RNG since Stata's RNG is inaccessible |
| Dense tree arrays | Tree-based methods | Cache locality from contiguous memory |
| Missing data handling | Any plugin that receives Stata data | Correctly interprets Stata's missing value representation |
You don't need all of these. Pick the ones relevant to your algorithm.
1. Pre-sorted Feature Indices (Tree Algorithms)
Sort feature values once, then scan linearly at each node:
typedef struct { double val; int idx; } ValIdx;
// Sort once per feature per tree
ValIdx *sorted[n_features];
for (int f = 0; f < n_features; f++) {
sorted[f] = calloc(n, sizeof(ValIdx));
for (int i = 0; i < n; i++) {
sorted[f][i].val = X[i * p + f];
sorted[f][i].idx = i;
}
qsort(sorted[f], n, sizeof(ValIdx), compare_validx);
}
// At each node: scan O(n) through sorted array
// Use bitset to track left/right membership2. Precomputed Distance Norms (KNN)
Exploit ||a-b||^2 = ||a||^2 + ||b||^2 - 2*a'b:
// Precompute ||donor||^2 once
double *donor_norms = calloc(n_donors, sizeof(double));
for (int i = 0; i < n_donors; i++) {
for (int j = 0; j < p; j++) {
donor_norms[i] += X_donors[i*p + j] * X_donors[i*p + j];
}
}
// For each test obs: compute dot products, assemble distances
double test_norm = dot(test, test, p);
for (int i = 0; i < n_donors; i++) {
double dot_val = dot(test, &X_donors[i*p], p);
dist[i] = test_norm + donor_norms[i] - 2.0 * dot_val;
}3. Quickselect for Partial Sorting
O(n) expected time to find k-th smallest (vs O(n log n) full sort):
int quickselect_partition(double *arr, int lo, int hi) {
double pivot = arr[hi];
int i = lo;
for (int j = lo; j < hi; j++) {
if (arr[j] <= pivot) {
double tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
i++;
}
}
double tmp = arr[i]; arr[i] = arr[hi]; arr[hi] = tmp;
return i;
}
void quickselect(double *arr, int n, int k) {
int lo = 0, hi = n - 1;
while (lo < hi) {
int p = quickselect_partition(arr, lo, hi);
if (p == k) return;
else if (p < k) lo = p + 1;
else hi = p - 1;
}
}4. Parallel Ensemble Training (pthreads)
#include <pthread.h>
typedef struct {
int model_id;
double *X, *y;
int n_train, n_features;
NeuralNet *model;
uint64_t seed;
} TrainArgs;
void *train_model(void *arg) {
TrainArgs *a = (TrainArgs *)arg;
// Each thread has its own RNG state seeded from a->seed
// Train model with bootstrap sample
return NULL;
}
// Launch parallel training
pthread_t threads[n_models];
TrainArgs args[n_models];
for (int m = 0; m < n_models; m++) {
args[m] = (TrainArgs){m, X, y, n_train, p, models[m], seed + m};
pthread_create(&threads[m], NULL, train_model, &args[m]);
}
for (int m = 0; m < n_models; m++) {
pthread_join(threads[m], NULL);
}5. XorShift RNG (Fast, Thread-Safe Random Numbers)
C plugins cannot access Stata's internal RNG (runiform(), rnormal()), so stochastic algorithms need their own. XorShift128+ is ideal because it is fast, has good statistical properties, and each thread can have its own independent state (thread-safe for pthreads). Seed from argv[] so the user controls reproducibility.
typedef struct {
uint64_t state[2];
} RNGState;
static uint64_t xorshift128plus(RNGState *rng) {
uint64_t s1 = rng->state[0];
uint64_t s0 = rng->state[1];
rng->state[0] = s0;
s1 ^= s1 << 23;
rng->state[1] = s1 ^ s0 ^ (s1 >> 17) ^ (s0 >> 26);
return rng->state[1] + s0;
}
static double rand_double(RNGState *rng) {
return (xorshift128plus(rng) >> 11) / 9007199254740992.0;
}
// Initialize per-thread RNG from seed
void init_rng(RNGState *rng, uint64_t seed) {
rng->state[0] = seed;
rng->state[1] = seed ^ 0x6a09e667f3bcc908ULL;
// Warm up
for (int i = 0; i < 20; i++) xorshift128plus(rng);
}6. Dense Arrays for Tree Structures
Instead of linked lists (which cause cache misses):
typedef struct {
int is_leaf;
int split_var;
double split_val;
int left_child; // index into node array
int right_child; // index into node array
double *leaf_values;
int n_leaf_values;
} TreeNode;
// Allocate all nodes as dense array
TreeNode *nodes = calloc(max_nodes, sizeof(TreeNode));7. Missing Data Handling in C
Stata represents missing values as very large doubles. Two patterns:
Pattern A: Plugin handles missing internally
for (ST_int obs = 1; obs <= nobs; obs++) {
SF_vdata(1, obs, &val);
if (SF_is_missing(val)) {
// This is a test observation — will predict
test_indices[n_test++] = obs - 1;
} else {
// This is a training observation
y_train[n_train] = val;
train_indices[n_train++] = obs - 1;
}
}.ado wrapper: Do NOT sort. Let the plugin scan.
Pattern B: Plugin expects sorted data
The .ado wrapper sorts non-missing first, missing second, and passes counts:
// argv[0] = n_train, argv[1] = n_test
// Rows 1..n_train are donors, rows n_train+1..nobs are test
int n_train = atoi(argv[0]);
int n_test = atoi(argv[1]);.ado wrapper: Sort by missing(depvar) before plugin call.
Document which pattern your plugin uses.
Testing Strategy for Translated Stata Packages
Overview
Testing a Stata package translated from Python/R requires three things: 1. Reference outputs from the original implementation 2. Stata tests that compare against those references 3. Integration tests that verify the Stata user experience
Layer 0: Repurpose the Original Test Suite
Purpose
Before writing any new tests, mine the original package's test suite for test data, test cases, and expected outputs. The original authors already found the edge cases and tricky inputs — don't reinvent them.
What to Extract
1. Test datasets. Copy or convert the original's test data into CSV for Stata. These are the most valuable artifacts — they represent inputs the original authors specifically chose to exercise edge cases.
2. Test assertions. Translate the original's test checks into Stata. If the Python test says assert model.predict(X) == expected, write the equivalent Stata assertion.
3. Edge case inputs. Look for tests that exercise boundary conditions: empty input, single records, all-identical values, missing data patterns, maximum-size inputs. These are the tests you'd otherwise have to discover through painful debugging.
4. Expected outputs. Run the original test suite and capture its outputs. Use these as reference data for your Stata fidelity tests.
How to Extract
# Find the original's test files
find /path/to/source/package -name "test_*.py" -o -name "tests/" -o -name "testthat/"
# For Python: run tests and capture output
cd /path/to/source/package && python -m pytest tests/ -v --tb=short
# For R: run tests
cd /path/to/source/package && Rscript -e "testthat::test_dir('tests/')"Generate a script that runs the original test suite, captures all inputs and expected outputs as CSV, and saves them in your Stata project's tests/ directory. Pin the exact package version so results are reproducible.
When the Original Has No Test Suite
Some packages have minimal or no tests. In this case, fall back to Layer 1 (generating reference data from scratch). But first check: documentation examples, vignettes, and README demos often contain implicit test cases with expected outputs.
Layer 1: Reference Data Generation
Purpose
Generate test inputs AND reference outputs using the original package. Save everything as CSV so Stata can load it.
Script Template: tests/generate_test_data.py (or .R)
#!/usr/bin/env python3
"""Generate reference test data for Stata package validation."""
import numpy as np
import pandas as pd
from original_package import OriginalModel
def generate_test_data(n, p, seed=42):
"""Generate data with known properties for validation."""
rng = np.random.default_rng(seed)
X = rng.standard_normal((n, p))
# Known signal — choose something appropriate for your package
beta = np.array([3.0 * np.exp(-i / 10) for i in range(p)])
y = X @ beta + 0.3 * X[:, 0]**2 + rng.normal(0, 0.5, n)
return X, y
def generate_validation_data():
"""Run the source implementation and save inputs + outputs."""
X, y = generate_test_data(n=1000, p=4, seed=42)
# Run original implementation
model = OriginalModel(param1=value1, param2=value2)
result = model.fit_or_run(X, y)
# Save inputs and reference outputs as CSV
df = pd.DataFrame(X, columns=[f'x{i+1}' for i in range(X.shape[1])])
df['y'] = y
# Save whatever the source produces — predictions, estimates, weights, etc.
# The columns you add here depend on what your command outputs.
df['ref_output'] = result.predictions # or .estimates, .weights, etc.
df.to_csv('test_data.csv', index=False)
if __name__ == '__main__':
generate_validation_data()Key Principles
1. Save complete inputs, not just outputs. Stata needs to run its own implementation on the same data. 2. Include ground truth when applicable, so you can verify both implementations against reality. 3. Use structured data with known signal so you can distinguish implementation bugs from method limitations. 4. Pin the source package version. Without this, your reference data may become irreproducible.
Layer 2: Correctness Tests
Core Principle
For any input, the Stata implementation should produce the same output as the source. What "same" means depends on the algorithm:
| Algorithm Nature | What to Check | Metric |
|---|---|---|
| Deterministic | Exact match | Max absolute deviation < ε (e.g., 1e-10) |
| Numerically sensitive | Near-exact match | Max relative deviation small; correlation ≈ 1.0 |
| Fundamentally stochastic | Substantive agreement | Choose a metric appropriate to the output (see below) |
Choosing the Right Metric
The comparison metric depends on what the command produces:
| Output Type | Appropriate Metrics |
|---|---|
| Point predictions | Correlation, MAE, max absolute deviation |
| Scalar estimates (coefficients, SEs) | Relative error, exact match |
| Classifications / labels | Agreement rate, confusion matrix |
| Distributions / densities | KS statistic, moment comparisons, QQ correlation |
| Weights / ranks | Rank correlation (Spearman), weight sum checks |
| Conditional quantiles | Quantile coverage rates, crossing rates |
Don't default to correlation for everything. It's the right metric for predicted values but meaningless for, say, a single scalar estimate.
Script Template: tests/run_tests.do
*! run_tests.do - Correctness validation against reference implementation
clear all
set more off
local total_tests 0
local passed_tests 0
local failed_tests 0
// ============================================================
// TEST: Output agrees with reference
// ============================================================
import delimited using "test_data.csv", clear
// Run Stata implementation
mycommand y x1 x2 x3 x4, [options]
// Compare Stata output to reference output
// Choose comparison appropriate to your output type:
// --- For predictions or continuous output ---
quietly correlate stata_output ref_output
local corr = r(rho)
gen double ad = abs(stata_output - ref_output)
quietly summarize ad
local max_dev = r(max)
local mean_dev = r(mean)
local total_tests = `total_tests' + 1
// Set threshold based on algorithm nature:
// deterministic: max_dev < 1e-10
// numerically sensitive: corr > 0.999
// stochastic: corr > 0.95 (or whatever is appropriate)
if `corr' > 0.99 {
di as res "PASS: correlation = `corr', max dev = `max_dev'"
local passed_tests = `passed_tests' + 1
}
else {
di as err "FAIL: correlation = `corr', max dev = `max_dev'"
local failed_tests = `failed_tests' + 1
}
// --- For scalar estimates ---
// local ref_value = [known value from reference]
// local stata_value = [r(estimate) or e(b)]
// local reldiff = abs(`stata_value' - `ref_value') / abs(`ref_value')
// assert `reldiff' < 1e-6
// ============================================================
// Summary
// ============================================================
di _n "Tests: `total_tests', Passed: `passed_tests', Failed: `failed_tests'"Always Also Check Against Ground Truth
Matching the source implementation is necessary but not sufficient. If both implementations are wrong in the same way, you'd never know. When ground truth is available (synthetic data with known parameters, held-out test sets), compare against that too.
Layer 3: Integration Tests
Script Template: tests/test_features.do
*! test_features.do - Feature verification
clear all
set more off
local n_tests 0
local n_pass 0
local n_fail 0
// TEST: Basic invocation works
sysuse auto, clear
capture noisily mycommand price mpg weight, [minimal options]
local n_tests = `n_tests' + 1
if _rc == 0 {
di as res "PASS: basic invocation"
local n_pass = `n_pass' + 1
}
else {
di as err "FAIL: basic invocation returned error `=_rc'"
local n_fail = `n_fail' + 1
}
// TEST: Each option/method works
foreach opt in option1 option2 option3 {
sysuse auto, clear
capture noisily mycommand price mpg weight, method(`opt')
local n_tests = `n_tests' + 1
if _rc == 0 {
di as res "PASS: method(`opt') works"
local n_pass = `n_pass' + 1
}
else {
di as err "FAIL: method(`opt') returned error `=_rc'"
local n_fail = `n_fail' + 1
}
}
// TEST: if/in conditions
sysuse auto, clear
mycommand price mpg weight if foreign == 1, [options]
// verify output is only produced for the specified subset
// TEST: replace option
sysuse auto, clear
mycommand price mpg weight, gen(test_var) [options]
capture noisily mycommand price mpg weight, gen(test_var) [options] replace
local n_tests = `n_tests' + 1
if _rc == 0 {
di as res "PASS: replace option works"
local n_pass = `n_pass' + 1
}
else {
di as err "FAIL: replace option error `=_rc'"
local n_fail = `n_fail' + 1
}
// TEST: Stored results
// After running command, verify r() or e() values are populated
mycommand price mpg weight, [options]
assert !missing(r(N)) // or whatever your command stores
// TEST: Edge cases
// - Single predictor
// - Many predictors
// - Small n
// - Constant variable
// - All identical values
// Summary
di _n "Total: `n_tests', Passed: `n_pass', Failed: `n_fail'"What to Test
1. Basic invocation — does the command run without error on simple data? 2. Every option — each option/method produces output without errors. 3. `if`/`in` conditions — subsetting works correctly. 4. `replace` option — calling twice with replace doesn't error. 5. Stored results — r() or e() values are populated correctly. 6. Edge cases — small n, high p, constant variables, collinear features. 7. Error handling — bad inputs produce informative error messages, not crashes.
Layer 4: Stress Tests
What to Stress
1. High dimensionality (p = 50, 100, 500): Does the method degrade gracefully? 2. Large n (n = 10,000+): Does it complete in reasonable time? 3. Memory (n p large): Does it crash or hang? 4. Correlated features: AR(1) structure tests numerical stability. 5. Near-singular data*: Multicollinearity stress test.
Stress Test Data Generation
from scipy.stats import multivariate_normal
# AR(1) correlation structure
rho = 0.5
cov = np.array([[rho ** abs(i-j) for j in range(p)] for i in range(p)])
X = multivariate_normal(np.zeros(p), cov).rvs(size=n)Running Tests
Batch Mode
stata-mp -b do tests/run_tests.do
stata-mp -b do tests/test_features.doStata writes output to run_tests.log and test_features.log in the current directory.
Checking Results
grep -E "PASS|FAIL|Total" run_tests.log
grep -E "PASS|FAIL|Total" test_features.logAdd *.log to .gitignore early. Log files are large and should not be committed.
Debugging Test Failures
Output Disagrees with Source
1. Check data sorting. Is the plugin receiving data in the order it expects? 2. Check missing value handling. Stata . vs Python NaN vs R NA — different semantics. 3. Check merge logic. Does merge_id survive preserve/restore? 4. Check normalization. Are inputs scaled the same way both implementations expect? 5. Run on trivially simple data (e.g., y = 2*x + 1) and verify by hand.
Plugin Returns All Missing
1. Check plugin loading. Is the correct platform plugin found? 2. Check variable count. Does SF_nvar() match what the plugin expects? 3. Check argument parsing. Are argv[] values correct? 4. Check observation count. Did keep if leave zero observations?
Tests Pass Locally But Fail on Another Platform
1. Integer sizes differ. Use int32_t/int64_t from <stdint.h>, not int/long. 2. Floating point order differs. Stochastic algorithms may produce different results. 3. pthreads behavior differs. Thread scheduling varies by OS.
Translating Python/R Packages into Stata
A complete workflow for porting a Python or R statistical package into a native Stata implementation with C plugin acceleration.
Mandatory: Start in Plan Mode
Every translation project MUST begin in plan mode. Before writing any implementation code, produce a complete plan document covering:
1. All features/options of the source package (exhaustive inventory) 2. Architecture decisions (wrap C++ backend vs. reimplement) 3. Phase-by-phase implementation order with dependencies 4. Test strategy (what test data/suites exist in the original package) 5. The multi-agent review loop baked into every implementation step (see "Multi-Agent Review Loop" below) 6. A final fidelity audit as the last step (see "Final Fidelity Audit" below)
Enter plan mode to produce this document. The plan must be approved before implementation begins. Every step in the plan should specify what gets built, what gets tested, and that the review loop runs before proceeding.
Phase 1: Scope and Understand the Source
Before writing any code, thoroughly understand the source package.
1. Check for a C/C++ backend or standalone library first. Many R packages (and some Python packages) have compiled backends — in R, check src/ for .c/.cpp/.h files; in Python, look for Cython (.pyx), C extensions, or cffi/ctypes bindings. Also search for standalone C++ libraries that implement the same algorithm (e.g., rapidfuzz-cpp for string matching, Eigen for linear algebra). If any C/C++ implementation exists, wrap it rather than reimplementing the algorithm from scratch. This gives you identical output (same code path), the same performance, far less code to write, and easier maintenance. Vendor all dependencies — header-only or otherwise — and statically link everything for all platforms. Binary size is not a concern. See "Wrapping an Existing C++ Backend" below.
2. Read the source package structure. Identify all public-facing functions, their signatures, inputs, outputs, and options. Map Python classes/functions to what will become Stata commands.
3. Identify the computational core. Separate the algorithm (what computes) from the interface (how users call it). In Python, the algorithm is usually in model classes; in Stata, it will be in C plugins (or wrapped C++ code).
4. Check the source license. The translated package inherits licensing obligations. MIT and BSD allow any re-use. GPL requires the Stata package to also be GPL. If the source is proprietary or has no license, get permission before translating.
5. Default: translate ALL features and options. The goal is full feature parity with the source package. Do not defer features unless they are fundamentally impossible in Stata (e.g., interactive visualization, language-specific I/O utilities). If a feature presents implementation difficulty, flag it in your plan with a concrete explanation of the challenge — but still plan to implement it. "This is hard" is not a reason to skip; "Stata has no concept of X" might be. Every option, parameter, and mode the original package exposes should be available in the Stata version.
6. Pin the source package version. Create requirements.txt (Python) or record the exact package version (R) so reference test data can be reproduced later. If the source changes, your tests become meaningless.
7. Repurpose the original package's test suite AND write new tests. Before writing tests from scratch, examine the source package's existing test suite (tests/, test_*.py, testthat/, etc.). Extract or adapt:
- Test data — at minimum, use the same datasets the original tests use. Copy them into your
tests/directory. - Test cases — translate the original's test assertions into Stata equivalents. If the original tests check that
predict(model, X)matches expected values, write the same check in Stata. - Edge cases — the original authors already found the tricky inputs. Don't reinvent them.
- Expected outputs — run the original test suite, capture outputs, and use them as reference data for your Stata tests.
This is far more valuable than writing tests from scratch because the original authors know where the bugs hide.
Then write additional tests beyond what the original provides. The original test suite may be incomplete, may not cover Stata-specific concerns (preserve/restore, if/in, replace, missing value handling), and doesn't test the .ado wrapper interface. For every implementation step, the agent should write whatever tests are needed to ensure both fidelity (matches the original) and functionality (works correctly as a Stata command). Don't limit yourself to what the original tested — if you see an untested code path, test it.
8. Map source concepts to Stata equivalents:
| Python/R Concept | Stata Equivalent |
|---|---|
| Function/method with args | .ado command with syntax options |
| Class with fit/predict | C plugin called from .ado wrapper |
| DataFrame I/O | Stata variables accessed via SF_vdata()/SF_vstore() |
| Return values | r() stored results, new variables via generate() |
| Optional parameters | Stata syntax options with defaults |
| Configuration object | Local macros in .ado file |
Phase 2: Choose Architecture
Three tiers of implementation. Choose based on what the source package provides and your performance needs.
Tier 1: Pure Stata (ado-files only)
- When: Simple operations, linear algebra Stata already does well (OLS, quantile regression)
- How: Use native Stata commands (
regress,qreg,matrix) inside.adowrappers - Performance: Limited. Loops over observations are extremely slow.
Tier 2: Wrap Existing C++ Backend (preferred when available)
- When: The source package has a C/C++ backend (many R packages do — check
src/for.cppfiles). Examples: grf, ranger, Rcpp-based packages, anything using Eigen/Armadillo. - How: Compile the existing C++ source into a Stata plugin. Write a thin
extern "C"wrapper around the library's API. The plugin internals are C++ — only thestata_callentry point needs C linkage. Seereferences/cpp_plugins.mdfor theextern "C"pattern, exception safety, and compilation commands. - Why this is better than reimplementing: Near-identical output (same core code path as the original — minor differences from compiler flags or RNG seeding are possible), same performance, far less code to write, and easier to update when the upstream package changes. You only write the glue between Stata's SDK and the library's API.
Tier 3: Plugin from Scratch (when no compiled backend exists)
- When: The source is pure Python/R with no compiled backend, AND no standalone C++ library implements the algorithm.
- How: Write C or C++ code using Stata's plugin SDK. See main SKILL.md for C patterns,
references/cpp_plugins.mdfor C++. - Mata is not recommended for compute-heavy algorithms — it's significantly slower than C/C++ and adds a layer of complexity without meaningful benefit for plugin-class workloads.
Recommendation: Always check for a C++ backend or standalone C++ library first. If one exists, wrap it (Tier 2) — this is faster to build, produces identical output, and is easier to maintain. Only fall back to Tier 3 when no compiled code exists to wrap.
Wrapping an Existing C++ Backend
When the source package has a C/C++ backend, this is the recommended approach. You compile the original C++ code into a Stata plugin rather than reimplementing the algorithm. For full practical details on C++ plugins (exception safety, platform-specific build commands, the extern "C" pattern, and standard library usage), see references/cpp_plugins.md. This section covers the translation-specific workflow.
Identifying a C++ Backend
- R packages: Check the
src/directory in the package source (e.g., on GitHub or CRAN). Look for.cpp,.c,.hfiles. Many high-performance R packages use Rcpp and have their core algorithms in C++. - Python packages: Look for Cython (
.pyx), C extensions (_module.c), orcffi/ctypesbindings. Some packages vendor C/C++ libraries. - Standalone C++ libraries: Many algorithms have standalone C++ implementations you can wrap directly. Examples: rapidfuzz-cpp (string matching), Eigen (linear algebra), nlohmann/json (JSON parsing). Search GitHub for
<algorithm-name> cppor<algorithm-name> header-only. - Header-only libraries: These are the easiest to wrap — vendor the headers into your
c_source/directory and add-I.at compile time. No separate linking needed. The headers get compiled into your plugin binary.
The Basic Pattern
// stata_wrapper.cpp — thin glue between Stata SDK and the C++ library
#include "stplugin.h"
#include "library_header.h" // the existing C++ library
extern "C" {
STDLL stata_call(int argc, char *argv[]) {
// 1. Parse arguments from argv[]
// 2. Read data from Stata via SF_vdata()
// 3. Call the C++ library's API
// 4. Write results back via SF_vstore()
// 5. Return 0 on success
}
}The extern "C" block gives stata_call C linkage so Stata can load it. Everything inside (and all code it calls) can be full C++: templates, classes, STL containers, Eigen matrices, etc.
Compilation Differences from Pure C
See references/cpp_plugins.md for full platform-specific build commands (darwin-arm64, darwin-x86_64, linux, windows cross-compilation).
| Aspect | C Plugin | C++ Plugin |
|---|---|---|
| Compiler | gcc | g++ (or gcc -lstdc++) |
| Standard | -std=c99 | -std=c++11 or later (match library requirements) |
| Entry point | stata_call() | extern "C" { stata_call() } |
| SDK files | stplugin.c compiled as C | stplugin.c compiled as C (keep separate, compile with gcc) |
| Header-only libs | N/A | -I/path/to/headers |
Important: Compile stplugin.c as C (with gcc), not C++. Then link the resulting object with your C++ code. This avoids name-mangling issues with the SDK symbols:
gcc -c -O3 -fPIC -DSYSTEM=APPLEMAC stplugin.c -o stplugin.o
g++ -c -O3 -fPIC -std=c++17 -DSYSTEM=APPLEMAC -I./library_headers stata_wrapper.cpp -o wrapper.o
g++ -bundle -o myplugin.darwin-arm64.plugin stplugin.o wrapper.oWhen to Wrap vs. Reimplement
| Scenario | Approach |
|---|---|
| Source has C++ backend (e.g., grf, ranger, Rcpp packages) | Wrap — identical output, same speed, less code |
| Standalone C++ library exists (RapidFuzz, Eigen, etc.) | Wrap — vendor the headers/source, write thin glue |
| Header-only C++ library | Wrap — just vendor headers and add -I, no linking needed |
| No C/C++ backend or library exists (pure Python/R) | Reimplement in C or C++ |
| C++ backend has massive dependency tree | Vendor what you need — binary size is not a concern |
The default is always to wrap when possible. Reimplementing from scratch is only for cases where no compiled code exists. Binary size is irrelevant — statically link everything (-static-libstdc++ -static-libgcc) and ship all platforms.
Advantages of Wrapping
1. Near-identical output — same code path as the original package, not a reimplementation that might diverge. Minor differences can arise from compiler flags, RNG seeding, or threading nondeterminism, but the core algorithm is the same. 2. Same performance — you get the original authors' optimizations for free 3. Less code to write — you only write the Stata SDK glue, not the algorithm 4. Easier maintenance — when the upstream library fixes bugs or adds features, you pull the update and recompile 5. Easier validation — if the code is the same, output agreement is nearly guaranteed
Phase 3: Package Structure
packagename/
├── stata.toc # net install table of contents
├── packagename.pkg # Package manifest
├── packagename.ado # Main command (dispatcher)
├── packagename_sub.ado # Method-specific wrapper (one per method)
├── packagename.sthlp # Help file (SMCL format)
├── *.plugin # Precompiled C plugins (4 platforms each)
├── c_plugin/ # C/C++ source (not distributed)
│ ├── lib/ # Vendored C++ library source (if wrapping)
└── tests/
├── generate_test_data.py # Reference outputs from source package
├── run_tests.do # Correctness tests
└── test_features.do # Feature verificationOne main command, multiple methods using a dispatcher pattern. Each method also callable directly for advanced users.
Subprograms in the same .ado file are NOT auto-discoverable. Only the first program define matching the filename is auto-found. Prefer separate .ado files.
Phase 4: Validating Against the Reference
The most critical translation-specific phase. See testing_strategy.md for detailed templates.
Core Principle
For any given input, the Stata implementation should produce the same output as the source. The acceptable tolerance depends on the algorithm's nature:
| Algorithm Nature | Expected Agreement | Example |
|---|---|---|
| Deterministic | Identical (within floating-point ε) | KNN, OLS, exact matching |
| Deterministic but numerically sensitive | Nearly identical (tiny deviations) | Matrix inversions, iterative solvers |
| Fundamentally stochastic | Substantively identical | Random forests, MCMC, neural nets |
"Substantively identical" means: applied to the same problem, both implementations should perform comparably. The right metric depends on what the command produces — correlation for predictions, relative error for scalar estimates, classification agreement for labels, distributional tests for density estimates, etc.
Reference Data Generation
Write a script in the source language that: 1. Creates synthetic data with known properties 2. Runs the original package on it 3. Saves inputs and outputs as CSV for Stata to load
Pin the exact source package version so results are reproducible.
What to Compare
Always compare against both the source implementation and known ground truth when possible. Matching the source perfectly is necessary but not sufficient — both implementations could be wrong in the same way.
Feature Coverage Tests
Every feature and option from the original package must have at least one dedicated test verifying: 1. Functionality — the feature runs without error and produces reasonable output 2. Fidelity — the output matches the source package (within tolerance appropriate to the algorithm)
This means if the original package has 15 options, the test suite should exercise all 15, not just the 5 easiest ones. Generate reference data from the source package for each feature/option combination that affects output.
Integration and Stress Tests
- Test every feature end-to-end (
if/in,replace, option combinations, edge cases) - Test "kitchen sink" combinations of multiple new features together
- Stress: high dimensions, large n, correlated features, near-singular data, boundary conditions
Debugging Test Failures
| Symptom | Likely Cause |
|---|---|
| Output disagrees with source | Sorting mismatch, missing data handling, merge key corruption, 0-vs-1 indexing |
| All missing output | Wrong variable count, plugin not loaded, zero obs after keep if |
| Platform differences | Integer sizes (int vs int32_t), thread scheduling |
Multi-Agent Review Loop
Every implementation step must pass a multi-agent review before proceeding. This is not optional — it is baked into every step of the plan. The loop catches bugs, missed edge cases, and architectural issues that a single pass misses.
The Loop
After completing each step (compile, test, verify no regressions):
1. Dispatch review agents in parallel. Aim for 2-3 agents with different focuses. If you have access to multiple AI models (Claude, GPT, Gemini, etc.), use different models for diversity of perspective. If not, dispatch multiple agents from the same model with different review prompts.
Suggested review focuses:
- Correctness agent: deep code review for bugs, edge cases, memory safety, architectural issues
- Completeness agent: review for missed requirements, untested paths, gaps vs. original package
- Consistency agent: verify behavior matches original package, check for API contract violations
Each agent receives:
- The step's requirements (from the plan)
- The diffs or full files that were changed
- The test results
- Instruction: "List any gaps, bugs, or issues. If everything looks correct and complete, say LGTM."
2. Collect findings. Read all agents' reports.
3. If any agent raised issues: Fix the identified problems, re-compile, re-test, then re-dispatch all review agents. Loop until all agents say LGTM.
4. If all agents say LGTM: The step is complete. Proceed to the next step.
Why Multi-Perspective Review
Different reviewers (whether different models or differently-prompted agents) catch different things. One may focus on algorithmic correctness while another catches a missing edge case or a documentation gap. The goal is genuine diversity of perspective, not three copies of the same review.
Writing Tests During Implementation
Each implementation step should include writing tests for the new functionality — not as an afterthought, but as part of the step itself. The agent should write whatever tests are needed to ensure:
- Fidelity — output matches the original package (using repurposed test data where available, new reference data where not)
- Functionality — the feature works correctly as a Stata command (if/in, replace, missing values, error cases)
- Edge cases — boundary conditions, empty inputs, degenerate cases
- Regressions — existing tests continue to pass
If a reviewer identifies an untested code path, writing the test is part of the fix, not a separate task.
What Reviewers Check
- Correctness: does the code do what the plan says?
- Edge cases: what happens with empty input, missing values, single-record blocks, etc.?
- Fidelity: does the behavior match the original package?
- Test coverage: is the new code tested? Are the tests meaningful (not just "runs without error")? Are there obvious untested paths?
- Regressions: do all existing tests still pass?
- Error handling: are failure modes handled gracefully?
Final Fidelity Audit
The last step of every plan is a comprehensive fidelity audit. This is not a casual review — it is a structured, multi-agent investigation of whether the Stata implementation has achieved full feature parity with the original package.
Audit Process
1. Dispatch a team of 3 subagents (aim for 2-3 agents with different review focuses; use multiple models if available). Each agent receives:
- The original package's documentation (README, API docs, help pages)
- The complete list of features/options from Phase 1 scoping
- The Stata implementation's help file and source code
- The full test suite and its results
- Instruction: "For every feature and option in the original package, verify that (a) it is implemented in the Stata version, (b) it is tested, and (c) the test demonstrates correct behavior. List any features that are missing, untested, or incorrectly implemented. Score the overall fidelity on a 1-10 scale."
2. Collect and merge findings. Compile a unified list of gaps from all agents.
3. If gaps exist: Create a new plan to close the remaining gaps. The new plan MUST also use the multi-agent review loop for every step, and MUST end with another fidelity audit. This is recursive — keep planning and implementing until the audit passes clean.
4. If no gaps (or only genuinely impossible features remain): The translation is complete. Document any intentional omissions in the help file with explanations.
What the Audit Checks
| Category | Check |
|---|---|
| Feature coverage | Every function/method in the original has a Stata equivalent |
| Option coverage | Every parameter/option is exposed and functional |
| Default values | Stata defaults match original defaults |
| Edge case handling | Missing data, empty input, boundary conditions match |
| Error messages | Invalid input produces helpful errors, not crashes |
| Output format | Stored results (r(), variables) contain equivalent information |
| Documentation | Help file accurately describes all implemented features |
| Test coverage | Every feature has at least one test; stochastic features have fidelity tests |
Closing the Loop
The audit-plan-implement-audit cycle continues until the team of reviewers agrees that parity has been achieved. There is no fixed cap on iterations. A typical project might need 1-2 audit rounds after the initial implementation plan completes.
Phase 5: Documentation
Be honest about what works, what has limitations, and how it was built. Don't claim features that are silently ignored. Only document what actually works.
Translation-Specific Pitfalls
1. Don't translate the interface literally. Python OOP maps poorly to Stata. Use Stata idioms. 2. Silently ignored options erode trust. Either implement or reject with an error. Never accept an option and silently do nothing. 3. Don't defer features by default. Plan to implement everything. Flag genuine impossibilities in the plan, but "this is complex" is not a reason to skip. 4. Pin your reference package version. Use requirements.txt. 5. Get correctness right first, optimize second. 6. Stata's `.` differs from Python's NaN. . sorts to the top and compares as larger than all numbers. 7. Be transparent about AI-assisted development. If the package was AI-generated or AI-assisted, note this in the README. Users appreciate honesty about how the code was produced.
Workflow Summary
1. START IN PLAN MODE — produce a complete plan document before writing any code
2. Read and understand source package — catalog ALL features, options, and modes
3. Repurpose original test suite — extract test data, cases, and expected outputs
4. Check for C/C++ backend (R: check src/, Python: check for Cython/C extensions)
5. Check license compatibility
6. Map ALL functions/options → Stata commands, identify compute-heavy algorithms
7. Decide: wrap C++ backend, write C/C++ from scratch, or pure Stata
8. Plan ALL features upfront — flag difficulties but do not defer by default
9. Bake multi-agent review loop into every plan step
10. Scaffold: .ado dispatcher, method wrappers, .sthlp, .pkg, .toc
11. For each implementation step:
a. Implement the feature
b. Write tests for fidelity and functionality (don't skip this)
c. Compile and run full test suite
d. Dispatch review agents (use multiple review agents with different focuses)
e. Fix any issues raised by reviewers (including writing missing tests)
f. Re-review until all agents say LGTM
g. Proceed to next step
12. Write reference data generator covering ALL features with pinned dependencies
13. Write Stata test suite: every feature tested for both functionality AND fidelity
14. Debug until outputs agree with original package
15. FINAL FIDELITY AUDIT — dispatch multi-agent team to verify full feature parity
16. If gaps found: create new plan (with review loop), implement, re-audit
17. Repeat until audit passes clean
18. Write honest README, package, distribute via net install