Skip to content

Commit fd3cc0d

Browse files
igerberclaude
andauthored
feat(conformal): one-sided alternatives + covariates in the CWZ proxy (#669)
* feat(conformal): one-sided alternatives + covariates in the CWZ proxy Extends the SyntheticControl conformal layer (CWZ 2021) with both user-locked extensions: (a) alternative={"two-sided","greater","less"} on conformal_test / conformal_confidence_intervals / conformal_average_effect. One-sided tests use the SIGNED average-effect statistic S(u)=T_*^{-1/2} sum u_t, grounded in Remark 1's statistic freedom + eq 2 (the paper has NO section 7 - the prior TODO/REGISTRY "(§7 signed-t)" wording was a Firpo-Possebom cross-contamination, fixed here). q=1 enforced for one-sided. CI inversion reports half-line HULLS: the infinite side attaches because p -> 1 in the accepted direction; contiguous is RECOMPUTED against the final reported ray (Algorithm 1 has no monotonicity guarantee after per-candidate proxy refits); an all-rejected scanned grid reports the infinite side + an uncertified NaN finite endpoint as grid_limited (never a false empty set). (b) covariates=[...] stacks pivoted-variable matching rows RAW into the constrained-LS proxy objective per the paper's note after eq 6; residuals/statistic stay outcome-only; exchangeability preserved. EXOGENEITY ASSUMPTION warned on every covariate run: Z(theta0) null-imputes only the outcome, so covariates enter as observed (no-intervention) values - a policy-affected covariate leaks treated post-period information into the proxy. Block-collapsed average-effect covariates collapse with the same T*-block structure; pointwise CIs precompute the covariate panel once and slice per (pre, t) sub-series. DiagnosticReport's conformal block carries alternative + covariates for all three kinds. Scope excludes (c) the AR/innovation-permutation path (TODO row rewritten to track only that remainder). Tests (5 local review rounds): hand-rolled signed-statistic permutation oracle; directional rejection; half-line endpoints bracketing the two-sided interval; missed-ray grid_limited (9-block fixture - the single-post-slot tie structure floors p at 1/n_blocks); deterministic non-contiguous patterns incl. rejected-toward-infinity; proxy-weight mechanism; exogenous covariate fixture + assumption-warning assertion; report propagation; permutation floor with covariates. All pre-existing conformal + SC + diagnostic-report suites pass unchanged. Reference scoping: the authors' scinference R package anchors only the base machinery; extensions anchor to paper formulas via the oracle tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lbd6nqWmg4snvvBmegwqiw * docs(registry): drop the contradictory outcomes-only phrase from the proxy bullet Review P3: the bullet both allowed covariate rows in the objective and said outcomes-only; the invariant is that residuals/statistic are outcome-only regardless of covariate rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lbd6nqWmg4snvvBmegwqiw --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 121b6b2 commit fd3cc0d

8 files changed

Lines changed: 692 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
223223
assert between the two (defense in depth).
224224

225225
### Added
226+
- **`SyntheticControl` conformal extensions: one-sided alternatives + covariates in the
227+
proxy (Chernozhukov-Wüthrich-Zhu 2021).** `conformal_test` / `conformal_confidence_intervals`
228+
/ `conformal_average_effect` gain `alternative={"two-sided","greater","less"}` — one-sided
229+
tests use the SIGNED average-effect statistic `S(û)=T_*^{-1/2}·Σ û_t` (grounded in Remark 1's
230+
statistic freedom; **the paper has no §7** — this PR also fixes the prior "(§7 signed-t)"
231+
citation, a cross-contamination from the Firpo-Possebom scope note), `q=1` enforced, and CI
232+
inversion yields half-lines with the infinite side genuinely accepted. `covariates=[...]`
233+
stacks pivoted-variable matching rows into the constrained-LS proxy objective per the paper's
234+
note after eq 6; rows stack raw (pre-scale as needed), residuals/statistic stay outcome-only,
235+
exchangeability preserved; block-collapsed average-effect covariates collapse with the same
236+
T*-block structure. Locked by a hand-rolled signed-statistic permutation oracle, directional
237+
rejection/half-line/composition tests, a proxy-weight mechanism check, and a permutation-floor
238+
invariant; all 31 pre-existing conformal tests pass unchanged.
226239
- **Opt-in `df_convention="cluster"` inference-df knob (DiD / TWFE / MultiPeriodDiD +
227240
`LinearRegression`).** Clustered analytical fits historically compute t-statistics,
228241
p-values, and CIs at the fitted **residual df** (`n − K_full`), while `fixest`/Stata use

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ The `Origin` column (Actionable tables) and the `PR` column (Deferred tables) bo
2828

2929
| Issue | Location | Origin | Effort | Priority |
3030
|-------|----------|--------|--------|----------|
31-
| `SyntheticControl` conformal (CWZ 2021) extensions: (a) one-sided / signed-`t` variants (§7); (b) covariates in the conformal proxy (`X_jt`, eqs 4/6 — current proxy is outcomes-only); (c) AR / innovation-permutation path (Lemmas 5-7) for time-series proxies. The joint test, pointwise CIs, and average-effect CI have landed. | `conformal.py`, `synthetic_control_results.py` | CWZ-2021 | Heavy | Low |
31+
| `SyntheticControl` conformal (CWZ 2021) AR / innovation-permutation path (Lemmas 5-7) for time-series proxies — the residual-permutation shortcut is only valid for time-permutation-invariant proxies (SC/Lasso/DiD); an AR proxy needs innovation permutation. One-sided alternatives (Remark 1 signed statistic) and proxy covariates (eq 4/6 note) SHIPPED 2026-07. | `diff_diff/conformal.py`, `diff_diff/synthetic_control_results.py` | CWZ-2021 | Heavy | Low |
3232
| `ContinuousDiD` CGBS-2024 extensions. (a) `covariates=` kwarg — **DONE (reg/dr)**; (b) discrete-treatment saturated regression (`treatment_type="discrete"`) — **DONE**; (c) lowest-dose-as-control per Remark 3.1 when `P(D=0)=0` (`control_group="lowest_dose"`) — **DONE** (discrete + continuous mass-point, single-cohort; estimand `ATT(d)−ATT(d_L)`; see REGISTRY Note #7). Remaining (all deferred `NotImplementedError`, documented): `estimation_method="ipw"` on the dose curve (scalar-adjustment / degenerate); `covariates=` × `survey_design=` (weighted OR + weighted nuisance IF); multi-cohort **heterogeneous-support** discrete aggregation (support-aware: average each dose only over the cohorts that observe it); **multi-cohort `lowest_dose`** (within-cohort `d_L` reference + support-aware cross-cohort aggregation); and **`covariates=` × `lowest_dose`** (conditional-PT-relative-to-`d_L` estimand). Single-cohort / 2-period / shared-support multi-cohort are supported. | `continuous_did.py` | CGBS-2024 | Heavy | Low |
3333
| TWFE's HC2/HC2-BM inline full-dummy build (`twfe.py:280-315`) duplicates the dummy-construction logic in `DifferenceInDifferences(fixed_effects=...)` (`estimators.py:478-486`). Extract a shared helper, or delegate TWFE's HC2/HC2-BM path to DiD's `fixed_effects=` branch (with TWFE-specific cluster-default threading), to reduce drift risk on FE naming / survey behavior / result-surface conventions. Substantive refactor — touches both estimators. | `twfe.py::fit`, `estimators.py::DifferenceInDifferences.fit` | follow-up | Heavy | Low |
3434

diff_diff/conformal.py

Lines changed: 163 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
**Proxy.** The proxy is the canonical CWZ constrained-LS synthetic control
2222
(eqs 3–4): simplex weights minimising ``Σ_{t}(Y^N_{1t} − Σ_j w_j Y^N_{jt})²``
2323
over **all** periods under the null, ``w ≥ 0, Σ w = 1``, **no V-matrix, no
24-
intercept, outcomes-only** (footnote 9: "we estimate w under the null based on
25-
all the data"). This is DISTINCT from the headline ADH V-matrix weights — CWZ's
24+
intercept, outcomes by default** (footnote 9: "we estimate w under the null
25+
based on all the data"); optional RAW covariate-matching rows stack into the
26+
same objective when ``covariates=`` is supplied (the paper's note after eq 6),
27+
with outcome-only residuals. This is DISTINCT from the headline ADH V-matrix weights — CWZ's
2628
exactness theory (Lemma 1, Appendix D exchangeability) requires a time-symmetric
2729
proxy, which the ADH pre-period V-fit is not. Reuses the Frank-Wolfe simplex
2830
solver :func:`diff_diff.utils._sc_weight_fw`.
@@ -53,6 +55,8 @@ def _cwz_proxy_fit(
5355
max_iter: int,
5456
min_decrease: float,
5557
init_weights: Optional[np.ndarray] = None,
58+
x1_rows: Optional[np.ndarray] = None,
59+
X0_rows: Optional[np.ndarray] = None,
5660
) -> Tuple[np.ndarray, np.ndarray, bool]:
5761
"""Fit the canonical CWZ constrained-LS SC proxy over ALL given periods (eq 4).
5862
@@ -67,16 +71,38 @@ def _cwz_proxy_fit(
6771
caller must pass a θ0-invariant scale (e.g. the pre-window outcome norm) so CI
6872
membership does not drift with the grid value via the tolerance.
6973
74+
**Covariates (CWZ §2.3.2, after eq 6):** "it is straightforward to
75+
incorporate (transformations of) covariates X_jt into the estimation
76+
problems (4) and (6)." ``x1_rows`` ``(R,)`` / ``X0_rows`` ``(R, J)`` are
77+
OPTIONAL covariate-matching rows stacked under the outcome rows in the
78+
same simplex LS objective — each row enters exactly like an outcome
79+
period, so the stacked objective remains invariant under time
80+
permutations of the outcome rows (exchangeability preserved; the
81+
covariate rows are fixed features of every permuted dataset). The
82+
RESIDUALS returned are OUTCOME rows only — the statistic and the
83+
permutation p-value are unchanged in form. Covariates are stacked RAW
84+
(no internal standardization): the paper's "(transformations of)"
85+
delegates scaling to the caller, and a covariate on a large scale
86+
dominates the fit exactly as it would in eq 4 — pre-scale accordingly.
87+
7088
Returns ``(w (J,), resid = y1 − Y0 @ w (T,), converged)``. ``J == 1`` is the
7189
degenerate single-donor case ``w = [1]`` (no optimisation).
7290
"""
7391
y1 = np.asarray(y1, dtype=float)
7492
Y0 = np.asarray(Y0, dtype=float)
93+
n_out = y1.shape[0]
94+
if x1_rows is not None:
95+
x1_rows = np.asarray(x1_rows, dtype=float)
96+
X0_rows = np.asarray(X0_rows, dtype=float)
97+
y1_fit = np.concatenate([y1, x1_rows])
98+
Y0_fit = np.vstack([Y0, X0_rows])
99+
else:
100+
y1_fit, Y0_fit = y1, Y0
75101
_, J = Y0.shape
76102
if J == 1:
77103
w = np.array([1.0], dtype=float)
78104
return w, y1 - Y0 @ w, True
79-
packed = np.column_stack([Y0, y1]) # (T, J+1); last column is the target
105+
packed = np.column_stack([Y0_fit, y1_fit]) # (T+R, J+1); last column is the target
80106
with warnings.catch_warnings():
81107
warnings.filterwarnings("ignore", message=r".*did not converge.*", category=UserWarning)
82108
w, converged = _sc_weight_fw(
@@ -89,7 +115,7 @@ def _cwz_proxy_fit(
89115
return_convergence=True,
90116
)
91117
w = np.asarray(w, dtype=float)
92-
return w, y1 - Y0 @ w, bool(converged)
118+
return w, y1[:n_out] - Y0[:n_out] @ w, bool(converged)
93119

94120

95121
# =============================================================================
@@ -160,7 +186,11 @@ def _make_perms(m: int, scheme: str, n_iid: int, rng: np.random.Generator) -> np
160186

161187

162188
def _cwz_pvalue(
163-
u: np.ndarray, post_mask: np.ndarray, perms: np.ndarray, q: Any
189+
u: np.ndarray,
190+
post_mask: np.ndarray,
191+
perms: np.ndarray,
192+
q: Any,
193+
alternative: str = "two-sided",
164194
) -> Tuple[float, float, int]:
165195
"""CWZ eq (2) permutation p-value for residuals ``u``.
166196
@@ -169,15 +199,34 @@ def _cwz_pvalue(
169199
identity ``π`` is in ``perms`` so ``p̂ ≥ 1/|Π|`` automatically (no extra ``+1``).
170200
Ties (``≥``) are counted conservatively. ``perms`` is the ``(|Π|, len(u))``
171201
integer index array from :func:`_make_perms`. Returns ``(p, S_observed, |Π|)``.
202+
203+
**One-sided alternatives (CWZ Remark 1).** The permutation argument is
204+
statistic-agnostic ("other test statistics can be used as well"), so
205+
``alternative="greater"`` uses the SIGNED average-effect statistic
206+
``S(û) = T_*^{-1/2}·Σ_{t>T0} û_t`` — large positive residuals (treated
207+
outcome above the counterfactual proxy, i.e. ``θ > θ0``) reject;
208+
``"less"`` negates the residuals and applies the same rule. NOTE the
209+
paper has NO §7 and no named one-sided section — the variants ground in
210+
Remark 1's statistic freedom + eq 2. ``q`` applies only to the two-sided
211+
``S_q`` family and must be 1 for one-sided alternatives (enforced by the
212+
callers).
172213
"""
173-
s_obs = _cwz_statistic(u, post_mask, q)
174-
post_perm = np.abs(u[perms][:, post_mask]) # (|Π|, T*)
175-
n_star = post_perm.shape[1]
176-
if q == _INF:
177-
s_perm = post_perm.max(axis=1)
214+
if alternative == "less":
215+
u = -u
216+
if alternative in ("greater", "less"):
217+
post = u[post_mask]
218+
n_star = post.size
219+
s_obs = float(np.sum(post) / np.sqrt(n_star))
220+
s_perm = u[perms][:, post_mask].sum(axis=1) / np.sqrt(n_star)
178221
else:
179-
s_perm = (post_perm**q).sum(axis=1) / np.sqrt(n_star)
180-
s_perm = s_perm ** (1.0 / q)
222+
s_obs = _cwz_statistic(u, post_mask, q)
223+
post_perm = np.abs(u[perms][:, post_mask]) # (|Π|, T*)
224+
n_star = post_perm.shape[1]
225+
if q == _INF:
226+
s_perm = post_perm.max(axis=1)
227+
else:
228+
s_perm = (post_perm**q).sum(axis=1) / np.sqrt(n_star)
229+
s_perm = s_perm ** (1.0 / q)
181230
n = perms.shape[0]
182231
tol = 1e-12 * max(abs(s_obs), 1.0)
183232
n_ge = int(np.sum(s_perm >= s_obs - tol))
@@ -200,6 +249,9 @@ def _single_null_pvalue(
200249
max_iter: int,
201250
min_decrease: float,
202251
init_weights: Optional[np.ndarray] = None,
252+
alternative: str = "two-sided",
253+
x1_rows: Optional[np.ndarray] = None,
254+
X0_rows: Optional[np.ndarray] = None,
203255
) -> Dict[str, Any]:
204256
"""Build ``Z(θ0)``, fit the proxy under the null, and return the p-value.
205257
@@ -211,9 +263,15 @@ def _single_null_pvalue(
211263
y1n = np.asarray(y1_obs, dtype=float).copy()
212264
y1n[post_mask] = y1n[post_mask] - np.asarray(effect_on_post, dtype=float)
213265
w, resid, conv = _cwz_proxy_fit(
214-
y1n, Y0, max_iter=max_iter, min_decrease=min_decrease, init_weights=init_weights
266+
y1n,
267+
Y0,
268+
max_iter=max_iter,
269+
min_decrease=min_decrease,
270+
init_weights=init_weights,
271+
x1_rows=x1_rows,
272+
X0_rows=X0_rows,
215273
)
216-
p, s_obs, n = _cwz_pvalue(resid, post_mask, perms, q)
274+
p, s_obs, n = _cwz_pvalue(resid, post_mask, perms, q, alternative=alternative)
217275
return {"p_value": p, "s_observed": s_obs, "n_perms": n, "converged": conv, "weights": w}
218276

219277

@@ -292,6 +350,9 @@ def _invert_single_post(
292350
min_decrease: float,
293351
grid: Optional[np.ndarray] = None,
294352
n_grid: int = 100,
353+
alternative: str = "two-sided",
354+
x1_rows: Optional[np.ndarray] = None,
355+
X0_rows: Optional[np.ndarray] = None,
295356
) -> Dict[str, Any]:
296357
"""Invert a single-post-slot conformal test over a grid (CWZ Algorithm 1).
297358
@@ -301,8 +362,13 @@ def _invert_single_post(
301362
(``y1[post_idx] − θ``), the proxy refit (warm-started across the grid — FW is
302363
convex so warm-starting only affects speed, not the optimum), and the
303364
permutation p-value recomputed. With one post slot ``S_q`` reduces to
304-
``|û_post|`` for every ``q``, so ``q`` is inert here (fixed to 1). Returns the CI
305-
summary plus the ``grid`` table of ``(θ, p, in_set, converged)`` rows.
365+
``|û_post|`` for every ``q``, so ``q`` is inert here (fixed to 1); under
366+
``alternative="greater"``/``"less"`` the single-slot statistic is the SIGNED
367+
``û_post`` (resp. ``−û_post``), and the inversion yields a ONE-SIDED
368+
confidence set — a half-line whose finite endpoint is the reported bound
369+
(the infinite side is genuinely accepted, not grid-limited; the caller
370+
overrides the edge-touch status accordingly). Returns the CI summary plus
371+
the ``grid`` table of ``(θ, p, in_set, converged)`` rows.
306372
307373
When ``alpha < 1/|Π|`` every candidate has ``p ≥ 1/|Π| > alpha`` (the identity is
308374
in ``Π``), so NO value is ever rejected and the confidence set is the whole line:
@@ -323,7 +389,12 @@ def _invert_single_post(
323389
# relying on that naive residual). ``spread`` is the pre-fit residual noise scale.
324390
pre_sel = ~post_mask
325391
w_pre, resid_pre, _ = _cwz_proxy_fit(
326-
y1_obs[pre_sel], Y0[pre_sel], max_iter=max_iter, min_decrease=min_decrease
392+
y1_obs[pre_sel],
393+
Y0[pre_sel],
394+
max_iter=max_iter,
395+
min_decrease=min_decrease,
396+
x1_rows=x1_rows,
397+
X0_rows=X0_rows,
327398
)
328399
point_est = float(y1_obs[post_idx] - Y0[post_idx] @ w_pre)
329400
spread = float(np.std(resid_pre)) if resid_pre.size else 1.0
@@ -354,10 +425,16 @@ def _invert_single_post(
354425
y1n = y1_obs.copy()
355426
y1n[post_idx] = y1n[post_idx] - float(theta)
356427
w, resid, conv = _cwz_proxy_fit(
357-
y1n, Y0, max_iter=max_iter, min_decrease=min_decrease, init_weights=w_prev
428+
y1n,
429+
Y0,
430+
max_iter=max_iter,
431+
min_decrease=min_decrease,
432+
init_weights=w_prev,
433+
x1_rows=x1_rows,
434+
X0_rows=X0_rows,
358435
)
359436
w_prev = w
360-
p, _, _ = _cwz_pvalue(resid, post_mask, perms, 1)
437+
p, _, _ = _cwz_pvalue(resid, post_mask, perms, 1, alternative=alternative)
361438
pvals[i] = p
362439
converged[i] = conv
363440

@@ -368,6 +445,73 @@ def _invert_single_post(
368445
return out
369446

370447

448+
def _apply_one_sided_endpoints(res: Dict[str, Any], alternative: str) -> Dict[str, Any]:
449+
"""Convert a one-sided inversion's accepted half-line into ±inf endpoints.
450+
451+
Under ``alternative="greater"`` (H1: θ > θ0) small candidates are rejected
452+
and the accepted region extends to the grid's upper edge, so the upper
453+
endpoint becomes ``+inf`` and only a LOWER-edge touch keeps
454+
``status="grid_limited"``. ``"less"`` is the mirror image. No-op for
455+
``"two-sided"`` or for unbounded results (everything accepted).
456+
457+
**Hull convention (no monotonicity guarantee).** Algorithm 1 accepts any
458+
candidate with ``p > α`` after REFITTING the proxy at that candidate; a
459+
monotone p-curve is typical but NOT guaranteed, so an
460+
accepted/rejected/accepted pattern is possible. Exactly as in the
461+
two-sided convention, the reported endpoints are then the HULL of the
462+
accepted set (with the infinite side attached) and ``contiguous`` is
463+
RECOMPUTED against the final reported ray — any rejected scanned point
464+
strictly inside it (including between the finite hull edge and the
465+
attached infinity) flips ``contiguous=False``, so a rejected pocket is
466+
disclosed, not hidden (callers warn on it).
467+
An all-rejected grid (the scan missed the ray — narrow user ``bounds``)
468+
reports the infinite side + an UNCERTIFIED (NaN) finite endpoint with
469+
``grid_limited`` status: the ray always exists beyond the grid on the
470+
accepted side (``p → 1`` as ``θ0`` walks into the accepted direction).
471+
"""
472+
if alternative == "two-sided" or res["status"] == "unbounded":
473+
return res
474+
if res["status"] == "empty":
475+
# Every scanned grid point rejected. Under a one-sided alternative
476+
# the accepted ray ALWAYS exists beyond the grid in the accepted
477+
# direction (as θ0 → ±∞ on that side, the signed statistic walks
478+
# into the permutation distribution's far tail and p → 1), so an
479+
# all-rejected grid means the scan missed it — typically narrow
480+
# user-supplied bounds. Report the infinite side with the finite
481+
# endpoint UNCERTIFIED (NaN) and grid_limited status rather than a
482+
# (false) empty confidence set.
483+
if alternative == "greater":
484+
res["upper"] = float("inf")
485+
else:
486+
res["lower"] = -float("inf")
487+
res["status"] = "grid_limited"
488+
return res
489+
grid = res.get("grid") or []
490+
if not grid:
491+
return res
492+
in_first = bool(grid[0][2])
493+
in_last = bool(grid[-1][2])
494+
if alternative == "greater":
495+
res["upper"] = float("inf")
496+
res["status"] = "grid_limited" if in_first else "ran"
497+
# Recompute contiguity against the FINAL reported ray: the
498+
# underlying flag only covered the finite accepted hull, so a
499+
# rejected scanned point ABOVE the hull (inside [lower, +inf) after
500+
# the rewrite) must flip it (REGISTRY hull-disclosure contract).
501+
lower = res["lower"]
502+
res["contiguous"] = bool(res["contiguous"]) and all(
503+
bool(row[2]) for row in grid if row[0] > lower
504+
)
505+
else: # "less"
506+
res["lower"] = -float("inf")
507+
res["status"] = "grid_limited" if in_last else "ran"
508+
upper = res["upper"]
509+
res["contiguous"] = bool(res["contiguous"]) and all(
510+
bool(row[2]) for row in grid if row[0] < upper
511+
)
512+
return res
513+
514+
371515
# =============================================================================
372516
# Block collapse for the average-effect test (CWZ Appendix A.1)
373517
# =============================================================================

diff_diff/diagnostic_report.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2690,6 +2690,11 @@ def _scm_native(self, r: Any) -> Dict[str, Any]:
26902690
"scheme": ci_summary.get("scheme"),
26912691
"n_perms": _to_python_scalar(ci_summary.get("n_perms")),
26922692
"n_post": _to_python_scalar(ci_summary.get("n_post")),
2693+
# One-sided / covariate-proxy runs are uninterpretable without
2694+
# these two — a one-sided p-value or half-line CI reads wrong
2695+
# under the default two-sided assumption.
2696+
"alternative": ci_summary.get("alternative", "two-sided"),
2697+
"covariates": ci_summary.get("covariates"),
26932698
}
26942699
if kind == "joint":
26952700
block["q"] = ci_summary.get("q")

0 commit comments

Comments
 (0)