westata 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. westata/__init__.py +223 -0
  2. westata/conformance/__init__.py +37 -0
  3. westata/conformance/oracles/__init__.py +7 -0
  4. westata/conformance/oracles/invariants.py +124 -0
  5. westata/conformance/oracles/stata.py +270 -0
  6. westata/conformance/registry.py +180 -0
  7. westata/conformance/tolerances.py +91 -0
  8. westata/data/__init__.py +7 -0
  9. westata/data/weights.py +134 -0
  10. westata/errors.py +45 -0
  11. westata/estimators/__init__.py +164 -0
  12. westata/estimators/counts.py +1040 -0
  13. westata/estimators/did.py +840 -0
  14. westata/estimators/discrete.py +1616 -0
  15. westata/estimators/hdfe.py +677 -0
  16. westata/estimators/iv.py +1037 -0
  17. westata/estimators/limited.py +2323 -0
  18. westata/estimators/linear.py +349 -0
  19. westata/estimators/nonlinear.py +1683 -0
  20. westata/estimators/panel.py +1111 -0
  21. westata/estimators/panel_dynamic.py +496 -0
  22. westata/estimators/panel_iv.py +2224 -0
  23. westata/estimators/panel_nonlinear.py +2073 -0
  24. westata/estimators/quantile.py +1075 -0
  25. westata/estimators/quantile_boot.py +348 -0
  26. westata/estimators/resample.py +698 -0
  27. westata/estimators/survey.py +1296 -0
  28. westata/estimators/systems.py +1208 -0
  29. westata/estimators/tobit.py +611 -0
  30. westata/estimators/treatreg_effects.py +1304 -0
  31. westata/estimators/tseries.py +1607 -0
  32. westata/estimators/xtunitroot_ips.py +290 -0
  33. westata/estimators/xtunitroot_llc.py +405 -0
  34. westata/expressions/__init__.py +21 -0
  35. westata/expressions/explist.py +536 -0
  36. westata/formula/__init__.py +11 -0
  37. westata/formula/factor.py +114 -0
  38. westata/postestimation/__init__.py +56 -0
  39. westata/postestimation/api.py +447 -0
  40. westata/postestimation/contrast.py +582 -0
  41. westata/postestimation/lincom.py +133 -0
  42. westata/postestimation/margins.py +805 -0
  43. westata/postestimation/modeltests.py +431 -0
  44. westata/postestimation/nonlinear.py +746 -0
  45. westata/postestimation/predict.py +823 -0
  46. westata/postestimation/tests.py +210 -0
  47. westata/protocols.py +56 -0
  48. westata/random/__init__.py +9 -0
  49. westata/random/stata_rng.py +95 -0
  50. westata/report/__init__.py +7 -0
  51. westata/report/etable.py +219 -0
  52. westata/results/__init__.py +14 -0
  53. westata/results/base.py +464 -0
  54. westata/results/stata_return.py +67 -0
  55. westata/results/summary.py +323 -0
  56. westata/vce/__init__.py +36 -0
  57. westata/vce/cluster.py +100 -0
  58. westata/vce/dispatch.py +199 -0
  59. westata/vce/robust.py +131 -0
  60. westata/vce/ssc.py +75 -0
  61. westata-0.0.1.dist-info/METADATA +354 -0
  62. westata-0.0.1.dist-info/RECORD +65 -0
  63. westata-0.0.1.dist-info/WHEEL +5 -0
  64. westata-0.0.1.dist-info/licenses/LICENSE +21 -0
  65. westata-0.0.1.dist-info/top_level.txt +1 -0
westata/__init__.py ADDED
@@ -0,0 +1,223 @@
1
+ """Export postestimation and report helpers from the top-level package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from westata.data import WEIGHT_TYPES, WeightSpec, normalize_weights
6
+ from westata.expressions.explist import (
7
+ ExpListError,
8
+ ParsedExpressionList,
9
+ StoredResults,
10
+ parse_exp_list,
11
+ )
12
+ from westata.estimators import (
13
+ LlcResult,
14
+ levin_lin_chu,
15
+ IpsResult,
16
+ im_pesaran_shin,
17
+ Bootstrap,
18
+ Jackknife,
19
+ Permute,
20
+ SurveyDesign,
21
+ SvyLogit,
22
+ SvyMean,
23
+ SvyProportion,
24
+ SvyRegress,
25
+ SvyTotal,
26
+ AIPW,
27
+ Didregress,
28
+ IPW,
29
+ NearestNeighbourMatching,
30
+ RegressionAdjustment,
31
+ TreatmentEffectResult,
32
+ Xtdidregress,
33
+ Frontier,
34
+ Nl,
35
+ Eivreg,
36
+ Manova,
37
+ ManovaResult,
38
+ Mvreg,
39
+ Sureg,
40
+ XtAbond,
41
+ XtLogit,
42
+ XtNbreg,
43
+ XtPoisson,
44
+ XtProbit,
45
+ XtTobit,
46
+ XtGls,
47
+ XtIvreg,
48
+ XtPcse,
49
+ XtUnitroot,
50
+ Bsqreg,
51
+ Sqreg,
52
+ QuantileRegression,
53
+ RobustRegression,
54
+ Etregress,
55
+ Heckman,
56
+ Intreg,
57
+ Truncreg,
58
+ TruncatedNegativeBinomial,
59
+ TruncatedPoisson,
60
+ ZeroInflatedNegativeBinomial,
61
+ ZeroInflatedPoisson,
62
+ ARIMA,
63
+ FEOLS,
64
+ GARCH,
65
+ IV2SLS,
66
+ IVGMM,
67
+ IVLIML,
68
+ OLS,
69
+ VAR,
70
+ VECM,
71
+ BetweenOLS,
72
+ FirstDifferenceOLS,
73
+ Logit,
74
+ NegativeBinomial,
75
+ NegativeBinomialNB1,
76
+ OrderedLogit,
77
+ OrderedProbit,
78
+ PanelOLS,
79
+ Poisson,
80
+ Probit,
81
+ RandomEffects,
82
+ Tobit,
83
+ UnitRootResult,
84
+ unitroot,
85
+ )
86
+ from westata.errors import (
87
+ ConsistencyError,
88
+ ConvergenceError,
89
+ FormulaError,
90
+ UnsupportedError,
91
+ WeightError,
92
+ WestataError,
93
+ )
94
+ from westata.postestimation import (
95
+ HausmanResult,
96
+ LincomResult,
97
+ LRTestResult,
98
+ MarginsResult,
99
+ WaldTest,
100
+ contrast,
101
+ hausman,
102
+ lrtest,
103
+ margins,
104
+ nlcom,
105
+ pwcompare,
106
+ testnl,
107
+ )
108
+ from westata.protocols import SSC
109
+ from westata.report import etable, to_latex, to_markdown
110
+
111
+ __version__ = "0.0.1"
112
+
113
+
114
+ def ssc(**kwargs: object) -> SSC:
115
+ """Convenience constructor mirroring ``ws.ssc(adj=True, ...)``."""
116
+
117
+ return SSC(**kwargs) # type: ignore[arg-type]
118
+
119
+
120
+ __all__ = [
121
+ "ParsedExpressionList",
122
+ "StoredResults",
123
+ "ExpListError",
124
+ "parse_exp_list",
125
+ "LlcResult",
126
+ "levin_lin_chu",
127
+ "IpsResult",
128
+ "im_pesaran_shin",
129
+ "Permute",
130
+ "Bootstrap",
131
+ "Jackknife",
132
+ "SvyLogit",
133
+ "SvyRegress",
134
+ "SvyProportion",
135
+ "SvyTotal",
136
+ "SvyMean",
137
+ "SurveyDesign",
138
+ "TreatmentEffectResult",
139
+ "NearestNeighbourMatching",
140
+ "AIPW",
141
+ "IPW",
142
+ "RegressionAdjustment",
143
+ "Xtdidregress",
144
+ "Didregress",
145
+ "Frontier",
146
+ "Nl",
147
+ "ManovaResult",
148
+ "Manova",
149
+ "Eivreg",
150
+ "Mvreg",
151
+ "Sureg",
152
+ "XtAbond",
153
+ "XtNbreg",
154
+ "XtPoisson",
155
+ "XtTobit",
156
+ "XtProbit",
157
+ "XtLogit",
158
+ "XtUnitroot",
159
+ "XtPcse",
160
+ "XtGls",
161
+ "XtIvreg",
162
+ "Bsqreg",
163
+ "Sqreg",
164
+ "RobustRegression",
165
+ "QuantileRegression",
166
+ "TruncatedNegativeBinomial",
167
+ "TruncatedPoisson",
168
+ "ZeroInflatedNegativeBinomial",
169
+ "ZeroInflatedPoisson",
170
+ "Etregress",
171
+ "Heckman",
172
+ "Intreg",
173
+ "Truncreg",
174
+ "HausmanResult",
175
+ "LRTestResult",
176
+ "pwcompare",
177
+ "contrast",
178
+ "hausman",
179
+ "lrtest",
180
+ "testnl",
181
+ "nlcom",
182
+ "SSC",
183
+ "ssc",
184
+ "OLS",
185
+ "IV2SLS",
186
+ "IVLIML",
187
+ "IVGMM",
188
+ "FEOLS",
189
+ "PanelOLS",
190
+ "RandomEffects",
191
+ "BetweenOLS",
192
+ "FirstDifferenceOLS",
193
+ "Logit",
194
+ "Probit",
195
+ "Poisson",
196
+ "NegativeBinomial",
197
+ "OrderedLogit",
198
+ "OrderedProbit",
199
+ "Tobit",
200
+ "ARIMA",
201
+ "VAR",
202
+ "VECM",
203
+ "GARCH",
204
+ "UnitRootResult",
205
+ "unitroot",
206
+ "WaldTest",
207
+ "LincomResult",
208
+ "MarginsResult",
209
+ "margins",
210
+ "etable",
211
+ "to_latex",
212
+ "to_markdown",
213
+ "WEIGHT_TYPES",
214
+ "WeightSpec",
215
+ "normalize_weights",
216
+ "WestataError",
217
+ "ConsistencyError",
218
+ "ConvergenceError",
219
+ "FormulaError",
220
+ "UnsupportedError",
221
+ "WeightError",
222
+ "__version__",
223
+ ]
@@ -0,0 +1,37 @@
1
+ """Conformance harness: evidence registry, tolerances and oracle adapters.
2
+
3
+ Design spec: ``docs/superpowers/specs/2026-10-22-westata-design.md`` section
4
+ 11. Numeric claims against Stata are only as strong as their evidence level,
5
+ so the suite keeps the claim (registry), the yardstick (tolerances) and the
6
+ checkable identities (oracles) together.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from westata.conformance.oracles.invariants import check_ols_invariants
12
+ from westata.conformance.registry import (
13
+ VALID_LEVELS,
14
+ VALID_SOURCE_TYPES,
15
+ Evidence,
16
+ load_registry,
17
+ validate_registry,
18
+ )
19
+ from westata.conformance.tolerances import (
20
+ LINEAR_RTOL,
21
+ MLE_RTOL,
22
+ MLE_SE_RTOL,
23
+ assert_close,
24
+ )
25
+
26
+ __all__ = [
27
+ "LINEAR_RTOL",
28
+ "MLE_RTOL",
29
+ "MLE_SE_RTOL",
30
+ "VALID_LEVELS",
31
+ "VALID_SOURCE_TYPES",
32
+ "Evidence",
33
+ "assert_close",
34
+ "check_ols_invariants",
35
+ "load_registry",
36
+ "validate_registry",
37
+ ]
@@ -0,0 +1,7 @@
1
+ """Oracle adapters for Stata conformance (design spec section 11.2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from westata.conformance.oracles.invariants import check_ols_invariants
6
+
7
+ __all__ = ["check_ols_invariants"]
@@ -0,0 +1,124 @@
1
+ """Invariant oracles: identities every correct OLS fit must satisfy.
2
+
3
+ Design spec: ``docs/superpowers/specs/2026-10-22-westata-design.md`` section
4
+ 11.2 (``InvariantOracle``). Least squares is defined by the normal equations,
5
+ so any estimator (our own, statsmodels, Stata) must produce residuals
6
+ orthogonal to the design matrix, and — when a constant is included — residuals
7
+ that sum to zero. These checks need no Stata license and no fixture.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+
14
+ import numpy as np
15
+
16
+
17
+ def _as_float_array(value: object, name: str) -> np.ndarray:
18
+ """Coerce ``value`` to a float array, raising ``TypeError`` on garbage."""
19
+
20
+ try:
21
+ return np.asarray(value, dtype=float)
22
+ except (TypeError, ValueError) as exc:
23
+ raise TypeError(
24
+ f"{name} must be numeric, got {type(value).__name__}: {value!r}"
25
+ ) from exc
26
+
27
+
28
+ def check_ols_invariants(
29
+ X: np.ndarray,
30
+ resid: np.ndarray,
31
+ params: np.ndarray,
32
+ *,
33
+ has_intercept: bool,
34
+ atol: float = 1e-10,
35
+ ) -> None:
36
+ """Assert the least-squares normal equations for a fitted model.
37
+
38
+ Checks
39
+ ------
40
+ 1. ``X.T @ resid ~= 0`` (residuals orthogonal to every regressor).
41
+ 2. When ``has_intercept`` is true, ``sum(resid) ~= 0``.
42
+
43
+ Floating-point orthogonality is judged against the problem scale:
44
+ ``max|X.T @ resid|`` is compared with ``atol * ||X||_inf * ||resid||_inf *
45
+ nobs``. A raw absolute threshold would reject exact solves on
46
+ large-magnitude regressors (e.g. wages measured in thousands), because
47
+ cancellation alone leaves a residual gradient of order
48
+ ``eps * ||X|| * ||resid|| * nobs``.
49
+
50
+ Parameters
51
+ ----------
52
+ X:
53
+ ``nobs x nparams`` design matrix.
54
+ resid:
55
+ ``nobs`` residual vector of the fitted model.
56
+ params:
57
+ Fitted coefficient vector, shape ``(nparams,)``. It is not used in the
58
+ identity itself, but requiring it keeps callers from pairing residuals
59
+ with the wrong design matrix.
60
+ has_intercept:
61
+ Whether the model includes a constant term; only then is
62
+ ``sum(resid) = 0`` expected.
63
+ atol:
64
+ Scale-relative tolerance, positive and finite.
65
+
66
+ Raises
67
+ ------
68
+ ValueError
69
+ On malformed inputs (wrong shapes, non-finite data, bad ``atol``).
70
+ AssertionError
71
+ When the normal equations or the intercept condition do not hold.
72
+ """
73
+
74
+ X_arr = _as_float_array(X, "X")
75
+ resid_arr = _as_float_array(resid, "resid")
76
+ params_arr = _as_float_array(params, "params")
77
+
78
+ if X_arr.ndim != 2 or X_arr.shape[0] == 0 or X_arr.shape[1] == 0:
79
+ raise ValueError(f"X must be a non-empty 2-D array, got shape {X_arr.shape}")
80
+ nobs, nparams = X_arr.shape
81
+ if params_arr.shape != (nparams,):
82
+ raise ValueError(
83
+ f"params must have shape ({nparams},) to match X, got {params_arr.shape}"
84
+ )
85
+ if resid_arr.shape != (nobs,):
86
+ raise ValueError(
87
+ f"resid must have shape ({nobs},) to match X, got {resid_arr.shape}"
88
+ )
89
+ if not math.isfinite(atol) or atol <= 0:
90
+ raise ValueError(f"atol must be positive and finite, got {atol!r}")
91
+ if not (np.all(np.isfinite(X_arr)) and np.all(np.isfinite(resid_arr))):
92
+ raise ValueError("X and resid must contain only finite values")
93
+
94
+ # 1) Normal equations: X.T @ resid == 0.
95
+ gradient = X_arr.T @ resid_arr
96
+ gradient_scale = max(
97
+ 1.0,
98
+ float(np.max(np.abs(X_arr))) * float(np.max(np.abs(resid_arr))) * nobs,
99
+ )
100
+ gradient_threshold = atol * gradient_scale
101
+ max_gradient = float(np.max(np.abs(gradient)))
102
+ if max_gradient > gradient_threshold:
103
+ raise AssertionError(
104
+ "OLS normal equations violated: max |X.T @ resid| = "
105
+ f"{max_gradient:.6e} exceeds scale-adjusted threshold "
106
+ f"{gradient_threshold:.6e} "
107
+ f"(atol={atol:.3g}, scale={gradient_scale:.6e})"
108
+ )
109
+
110
+ # 2) Intercept condition: sum(resid) == 0.
111
+ if has_intercept:
112
+ total_resid = abs(float(np.sum(resid_arr)))
113
+ sum_scale = max(1.0, nobs * float(np.max(np.abs(resid_arr))))
114
+ sum_threshold = atol * sum_scale
115
+ if total_resid > sum_threshold:
116
+ raise AssertionError(
117
+ "OLS intercept invariant violated: |sum(resid)| = "
118
+ f"{total_resid:.6e} exceeds scale-adjusted threshold "
119
+ f"{sum_threshold:.6e} "
120
+ f"(atol={atol:.3g}, scale={sum_scale:.6e})"
121
+ )
122
+
123
+
124
+ __all__ = ["check_ols_invariants"]
@@ -0,0 +1,270 @@
1
+ """Stata oracle: run real Stata and capture ``e()`` for conformance checks.
2
+
3
+ Requires StataNow/Stata 17+ with the bundled ``pystata`` package and a valid
4
+ licence. This module is a *test* dependency; nothing in ``westata`` imports it
5
+ at runtime.
6
+
7
+ Two properties matter for reproducible evidence:
8
+
9
+ * Results are captured as plain floats/arrays and can be written to JSON, so a
10
+ comparison can be replayed on a machine without Stata.
11
+ * Every capture records ``c(stata_version)`` and ``c(version)``, because Stata
12
+ results are only meaningful relative to a version.
13
+
14
+ Typical use::
15
+
16
+ with StataOracle(stata_dir=r"D:\\Program Files\\StataNow19", edition="mp") as so:
17
+ so.run("sysuse auto, clear")
18
+ so.run("regress mpg weight foreign")
19
+ capture = so.capture()
20
+ capture["b"] # coefficients, in e(b) order
21
+ capture["V"] # covariance matrix
22
+ capture["scalars"] # e(N), e(df_r), e(r2), ...
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import sys
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import Any, Iterable, Sequence
33
+
34
+ import numpy as np
35
+
36
+ __all__ = ["StataCapture", "StataOracle", "stata_available"]
37
+
38
+ #: Default install location on this machine; override via ``stata_dir``.
39
+ DEFAULT_STATA_DIR = r"D:\Program Files\StataNow19"
40
+
41
+
42
+ def stata_available() -> bool:
43
+ """Whether a Stata installation with ``pystata`` is reachable."""
44
+
45
+ candidate = Path(DEFAULT_STATA_DIR) / "utilities"
46
+ return candidate.is_dir()
47
+
48
+
49
+ def _strip_prefix(key: str) -> str:
50
+ """``"e(N)" -> "N"``; leave already-bare names alone."""
51
+
52
+ if key.startswith("e(") and key.endswith(")"):
53
+ return key[2:-1]
54
+ return key
55
+
56
+
57
+ @dataclass
58
+ class StataCapture:
59
+ """Everything a conformance check needs from one Stata estimation.
60
+
61
+ Attributes
62
+ ----------
63
+ b:
64
+ Coefficient vector in ``e(b)`` order.
65
+ V:
66
+ Covariance matrix of the estimators.
67
+ scalars:
68
+ Scalar ``e()`` results keyed without the ``e()`` wrapper.
69
+ macros:
70
+ String-valued ``e()`` results (``cmd``, ``vce``, ...).
71
+ names:
72
+ Coefficient names in ``e(b)`` order.
73
+ version:
74
+ Stata version string the capture was produced with.
75
+ """
76
+
77
+ b: np.ndarray
78
+ V: np.ndarray
79
+ scalars: dict[str, float]
80
+ macros: dict[str, str]
81
+ names: list[str] = field(default_factory=list)
82
+ version: str = ""
83
+
84
+ def __getitem__(self, key: str) -> Any:
85
+ if key in self.scalars:
86
+ return self.scalars[key]
87
+ raise KeyError(f"{key!r} not in captured e() scalars: {sorted(self.scalars)}")
88
+
89
+ def to_dict(self) -> dict[str, Any]:
90
+ return {
91
+ "b": self.b.tolist(),
92
+ "V": self.V.tolist(),
93
+ "scalars": self.scalars,
94
+ "macros": self.macros,
95
+ "names": self.names,
96
+ "version": self.version,
97
+ }
98
+
99
+ def save(self, path: str | Path) -> Path:
100
+ path = Path(path)
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ path.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
103
+ return path
104
+
105
+ @classmethod
106
+ def load(cls, path: str | Path) -> "StataCapture":
107
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
108
+ return cls(
109
+ b=np.asarray(payload["b"], dtype=float),
110
+ V=np.asarray(payload["V"], dtype=float),
111
+ scalars={k: float(v) for k, v in payload["scalars"].items()},
112
+ macros=payload["macros"],
113
+ names=list(payload.get("names", [])),
114
+ version=payload.get("version", ""),
115
+ )
116
+
117
+
118
+ class StataOracle:
119
+ """Context manager that owns one embedded Stata session.
120
+
121
+ Parameters
122
+ ----------
123
+ stata_dir:
124
+ Stata installation directory (the one containing ``utilities/pystata``).
125
+ edition:
126
+ ``"mp"``, ``"se"`` or ``"be"``.
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ stata_dir: str = DEFAULT_STATA_DIR,
132
+ edition: str = "mp",
133
+ ) -> None:
134
+ self._stata_dir = stata_dir
135
+ self._edition = edition
136
+ self._stata: Any = None
137
+
138
+ # ------------------------------------------------------------ lifecycle
139
+ def __enter__(self) -> "StataOracle":
140
+ self.start()
141
+ return self
142
+
143
+ def __exit__(self, *exc_info: Any) -> None:
144
+ self.close()
145
+
146
+ def start(self) -> None:
147
+ utilities = str(Path(self._stata_dir) / "utilities")
148
+ if utilities not in sys.path:
149
+ sys.path.append(utilities)
150
+ from pystata import config
151
+
152
+ config.init(self._edition, splash=False)
153
+ from pystata import stata
154
+
155
+ self._stata = stata
156
+
157
+ def close(self) -> None:
158
+ # pystata has no explicit shutdown; dropping the reference is enough and
159
+ # avoids surprising the caller with a half-dead interpreter.
160
+ self._stata = None
161
+
162
+ # ---------------------------------------------------------------- running
163
+ def run(self, command: str, *, quietly: bool = True) -> None:
164
+ if self._stata is None:
165
+ raise RuntimeError("StataOracle is not started; use it as a context manager")
166
+ self._stata.run(command, quietly=quietly)
167
+
168
+ def run_all(self, lines: Iterable[str]) -> None:
169
+ for line in lines:
170
+ self.run(line)
171
+
172
+ def send_data(self, frame: Any, name: str = "df") -> None:
173
+ """Load a pandas DataFrame into Stata under ``name``."""
174
+
175
+ if self._stata is None:
176
+ raise RuntimeError("StataOracle is not started")
177
+ self._stata.pdataframe_to_data(frame, force=True)
178
+
179
+ def get_output(self, command: str) -> str:
180
+ """Run ``command`` and return its printed output.
181
+
182
+ ``pystata.run(..., echo=False)`` prints to the process stdout instead of
183
+ returning a string, so the output is captured by temporarily redirecting
184
+ ``sys.stdout``. Without this, output-parsing checks would see ``""`` and
185
+ silently "fail to parse" rather than reporting the real result.
186
+ """
187
+
188
+ if self._stata is None:
189
+ raise RuntimeError("StataOracle is not started")
190
+
191
+ import io
192
+ from contextlib import redirect_stdout
193
+
194
+ buffer = io.StringIO()
195
+ with redirect_stdout(buffer):
196
+ self._stata.run(command, echo=False)
197
+ return buffer.getvalue()
198
+
199
+ # ------------------------------------------------------------- capturing
200
+ def capture(self, *, names: Sequence[str] | None = None) -> StataCapture:
201
+ """Return the current ``e()`` results as a :class:`StataCapture`."""
202
+
203
+ if self._stata is None:
204
+ raise RuntimeError("StataOracle is not started")
205
+ raw = self._stata.get_ereturn()
206
+
207
+ b = None
208
+ V = None
209
+ scalars: dict[str, float] = {}
210
+ macros: dict[str, str] = {}
211
+
212
+ for key, value in raw.items():
213
+ name = _strip_prefix(key)
214
+ if value is None:
215
+ continue
216
+ if name == "b":
217
+ b = np.asarray(value, dtype=float).reshape(-1)
218
+ elif name == "V":
219
+ V = np.asarray(value, dtype=float)
220
+ elif isinstance(value, str):
221
+ macros[name] = value
222
+ elif isinstance(value, (int, float, np.floating, np.integer)):
223
+ scalars[name] = float(value)
224
+
225
+ if b is None or V is None:
226
+ raise RuntimeError(
227
+ "no e(b)/e(V) available; run an estimation command first"
228
+ )
229
+
230
+ # Coefficient order matters: e(b) is a row vector whose *column* stripes
231
+ # hold the names. Read them from Stata rather than inferring, otherwise
232
+ # a comparison could align the wrong coefficients and still "pass".
233
+ coefficient_names = list(names) if names else self._coefficient_names()
234
+
235
+ # Version is recorded because a conformance claim is only meaningful
236
+ # against a specific Stata release. ``display`` writes nothing to r(), so
237
+ # read the value through a local macro instead.
238
+ version = self._stata_version()
239
+
240
+ return StataCapture(
241
+ b=b,
242
+ V=V,
243
+ scalars=scalars,
244
+ macros=macros,
245
+ names=coefficient_names,
246
+ version=version,
247
+ )
248
+
249
+ def _coefficient_names(self) -> list[str]:
250
+ """Read the ``e(b)`` column stripes via ``sfi``."""
251
+
252
+ if self._stata is None:
253
+ return []
254
+ try:
255
+ names = self._stata.sfi.Matrix.getColNames("e(b)")
256
+ return [str(name) for name in names]
257
+ except Exception:
258
+ return []
259
+
260
+ def _stata_version(self) -> str:
261
+ """Return ``c(stata_version)``, or ``""`` when it cannot be read.
262
+
263
+ pystata exposes no direct Python accessor for ``c()`` macros and
264
+ ``display`` writes nothing to ``r()``, so this deliberately returns an
265
+ empty string rather than scraping stdout. Record the version in the
266
+ evidence registry by hand instead; a silently wrong version string would
267
+ be worse than an absent one.
268
+ """
269
+
270
+ return ""