test(infra): wire response-side drift through EXCLUSIONS map (PR0)#165
Conversation
Response-side spec drift (TestSpecDrift / TestWsSpecDrift additive +
required checks) is warn-only. The v2.1.0 Balance.balance_dollars
regression slipped through five rounds of review because additive
warnings don't fail CI. Promoting that warning to a hard-fail is the
highest-leverage fix in the response-side hardening stack.
This PR is the load-bearing infrastructure prerequisite. It wires the
response-side drift checks through the existing EXCLUSIONS allowlist
without changing warn->fail behavior yet (that's PR5).
Changes
-------
tests/_contract_support.py
- Rewrite module docstring + EXCLUSIONS comment header to document all
four consumers (TestRequestParamDrift, TestRequestBodyDrift,
TestSpecDrift, TestWsSpecDrift) and note that a single (fqn, field)
key may serve more than one drift check (e.g. CreateOrderRequest is
both a request body and a response schema).
- Add 4 new response-side entries: Market.response_price_units,
Order.action, Orderbook.orderbook_fp, TickerPayload.time. The other
3 entries listed in the issue (CreateRFQRequest.target_cost_centi_cents,
CreateRFQRequest.contracts_fp, CreateOrderRequest.sell_position_floor)
already existed as body-side entries with more detailed reasons and
are now reused by response-side checks via the new wiring; adding
duplicate keys would have overwritten the existing reasons.
tests/test_contracts.py
- _classify_drift now consults EXCLUSIONS.get((entry.sdk_model, field))
alongside entry.ignored_fields in both the additive and required
loops. This covers test_additive_drift, test_required_drift, and
test_ws_additive_drift (all three funnel through this helper).
- test_ws_required_drift inline loop gets the same EXCLUSIONS lookup.
- test_exclusion_map_is_current case 1 now walks all three source
maps (BODY_MODEL_MAP, CONTRACT_MAP, WS_CONTRACT_MAP) and flags an
entry stale only when the field is absent from every applicable
spec source.
Verification
------------
uv run pytest tests/test_contracts.py -> 372 passed, 57 warnings
uv run pytest tests/test_contracts.py -W error -> 57 failed (was 60)
uv run mypy kalshi/ -> Success: no issues
uv run mypy tests/_contract_support.py tests/test_contracts.py
-> 11 errors, all pre-existing on main (verified via git stash)
uv run ruff check . -> All checks passed!
Smoke-tested test_exclusion_map_is_current by flipping
Market.response_price_units to a bogus name (and separately
TickerPayload.time): both branches correctly fail with
"absent from every spec source (...)".
Note on the failure count
-------------------------
The issue's acceptance criterion 2 claimed "exactly 53 failures" under
-W error. Actual count is 57. The author appears to have assumed each
of the 7 allowlisted entries would convert a unique parametrized test
from fail->pass; in practice, only 3 tests have that property
(test_additive_drift[Orderbook], [CreateRFQRequest], [CreateOrderRequest]
-- the only ones where the allowlisted field was the sole drift bullet).
Market / Order / TickerPayload retain many other unfiltered drift
entries, so their tests still warn. The substantive intent -- EXCLUSIONS
is consulted by the response-side checks -- is verified by direct grep:
each of the 7 (model, field) keys no longer surfaces in any drift
warning, while every other drift bullet is preserved.
Closes #158.
Code Review — PR #165: Wire response-side drift through EXCLUSIONS mapOverall: This is a clean, focused PR that does exactly what it says. No production code is touched, the change surface is narrow, and the 4 new EXCLUSION entries are all well-reasoned. A few minor issues worth addressing before merge. What the PR does well
IssuesMinor:
|
| Item | Severity | Blocking? |
|---|---|---|
_load_asyncapi_spec() inside loop |
Minor perf | No |
Missing continue after "absent from every spec source" |
Minor correctness | No |
continue skips other spec sources on body_schema=None |
Latent footgun | No |
| No inline comment pointing to live WS branch coverage | Nit | No |
The PR is correct for the cases it targets, and the EXCLUSIONS wiring is exactly the right approach for this layer of the stack. The issues are pre-existing in character or low-probability edge cases. Recommend merging after addressing the _load_asyncapi_spec() hoisting (or confirming you're OK with the current behavior as a known TODO).
Three fixes from claude[bot]'s review (#165): 1. Lazy-cache `ws_spec` across the exclusion loop instead of reloading the AsyncAPI YAML on every WS-keyed entry. Sentinel-init outside the loop, populated on first use; reused for the rest. Currently exercised live by `TickerPayload.time`; cost scales 1x instead of Nx as the WS exclusion list grows. 2. Drop the `continue` after the BODY_MODEL_MAP -> METHOD_ENDPOINT_MAP resolution failure. The stale diagnostic still surfaces (it IS a real infrastructure bug), but other sources (CONTRACT_MAP, WS_CONTRACT_MAP) now still get checked. Prevents a latent false-positive 'absent from every spec source' when a shared model has a broken body-side ref but a valid response-side entry. 3. Add an inline comment noting that the `ws_spec` lazy-load branch is exercised live by `TickerPayload.time`, so future readers know there is real coverage of the WS source path. Not addressed: the dual-stale-message behavior when a field is both 'absent from spec' AND 'emitted by SDK'. Reviewer correctly notes this is pre-existing (predates this PR) and not blocking. Both messages carry independent diagnostic value (spec-moved vs SDK-implemented are different remediations), so keeping them separate is defensible; not in scope for PR0. Verification (re-run after fixes): uv run pytest tests/test_contracts.py -> 372 passed, 57 warnings uv run ruff check . -> All checks passed! uv run mypy kalshi/ -> Success: no issues Smoke-retested all three test_exclusion_map_is_current branches: - REST stale (Market.bogus_rest_xyz) -> FAILED as expected - WS stale (TickerPayload.bogus_ws_xyz) -> FAILED as expected (exercises lazy-load) - baseline (no patch) -> PASSED
|
Thanks for the review. Addressed in 36b9ab8. 1.
|
Follow-up review — updated diff after 36b9ab8All three issues from the prior review have been addressed. Reviewing the final diff state: Fixes confirmed
Dual stale messages (issue 2, intentionally not addressed) — The author's rationale is sound: "spec coordinate stale" and "SDK now emits it" are independent facts; surfacing both tells a maintainer which side moved. Leaving as-is is the right call. Remaining observations (non-blocking)
VerdictReady to merge. The core change is minimal and correct, all review issues are addressed, and the |
Cuts v2.2.0 — the response-side spec drift hardening stack (#157). Version bumps: - pyproject.toml: 2.1.0 → 2.2.0 - kalshi/__init__.py: 2.1.0 → 2.2.0 Docs: - CHANGELOG.md: finalize ## Unreleased → ## 2.2.0 — 2026-05-19 with a release-summary headline. The Added/Changed/Fixed entries written incrementally across #166–#169 stay as-is. - ROADMAP.md: add v2.2.0 to Shipped with a coverage summary; drop the two "Next milestone" items now done by this release (response-side drift detection; WS envelope extra=allow); add two new follow-ups (map remaining REST sub-models into CONTRACT_MAP; required-but- optional drift policy decision). - CLAUDE.md: active milestone → post-v2.2. Release scope (65 new optional fields + warn→fail flip + 1 bugfix): - REST response models: Market +11, Order +8, Fill +4, Event +3, EventMetadata +2, Settlement +1, Trade +2, IncentiveProgram +1, RFQ +1, Quote +3, OrderGroup +1, GetOrderGroupResponse +1, CreateOrderGroupResponse +2 (#166, #167, #168). - WS payload models: 33 fields across 11 payloads — Unix-ms timestamps (`*_ts_ms`), outcome_side/book_side direction encoding, MVE linkage, RFQ/Quote context echoes (#168). - Test infrastructure: additive drift now hard-fails CI; unmapped WS models hard-fail; required-but-optional stays warn-only (~204 entries, separate policy decision). EXCLUSIONS allowlist wired through response-side checks (#165). ErrorPayload registered in WS_CONTRACT_MAP; WS envelopes/helpers all use extra=allow (#168). - Bugfix: dropped le=32 cap on 6 subaccount request fields; demo allocates ephemeral subaccount numbers above 32 (observed 41 in nightly, 44 locally). Spec defines no upper bound (#169). No API breaks. All changes additive or relaxing. Bump is semver-minor. Verification: - uv run pytest tests/ --ignore=tests/integration -q -> 2023 passed, 35 warnings (all required-drift, kept warn-only) - uv run pytest tests/integration/test_subaccounts.py::TestSubaccountsSync::test_transfer_between_subaccounts -> 1 passed against live demo (#164 unblocked end-to-end) - uv run mypy kalshi/ -> Success: no issues found in 76 source files - uv run ruff check . -> All checks passed! - kalshi.__version__ check -> 2.2.0 After merge, push tag v2.2.0 to trigger the release workflow (.github/workflows/release.yml): build sdist+wheel → PyPI trusted- publish → GitHub release with CHANGELOG section as notes.
Closes #158. PR0 of the response-side spec-drift hardening stack (#157). Subsequent PRs (#PR1–#PR4) backfill the real model gaps; #PR5 flips warn→fail.
What this changes
tests/_contract_support.pyTestRequestParamDrift,TestRequestBodyDrift,TestSpecDrift(additive + required),TestWsSpecDrift(additive + required). Note that a single(fqn, field)key may serve more than one check—e.g.CreateOrderRequestis both a request body and a response schema in the spec.Market.response_price_units(spec_deprecated)Order.action(spec_deprecated)Orderbook.orderbook_fp(wire_normalization)TickerPayload.time(spec_deprecated)The other 3 entries listed in the issue (
CreateRFQRequest.target_cost_centi_cents,CreateRFQRequest.contracts_fp,CreateOrderRequest.sell_position_floor) already existed as body-side entries with more detailed reasons; they are now reused by response-side checks via the new wiring. Adding duplicate keys would have overwritten the existing reasons.tests/test_contracts.py_classify_driftnow consultsEXCLUSIONS.get((entry.sdk_model, field))alongsideentry.ignored_fieldsin both the additive and required loops. One change here coverstest_additive_drift,test_required_drift, andtest_ws_additive_drift(all three funnel through this helper).test_ws_required_driftinline loop gets the sameEXCLUSIONSlookup.test_exclusion_map_is_currentcase 1 now walks all three source maps (BODY_MODEL_MAP,CONTRACT_MAP,WS_CONTRACT_MAP) and flags a key stale only when the field is absent from every applicable spec source. Theelsebranch's error message now also mentionskalshi.ws.models.*as a valid prefix.No production code (
kalshi/**) touched.Verification
Smoke-tested
test_exclusion_map_is_currentby temporarily flippingMarket.response_price_unitsand (separately)TickerPayload.timeto bogus field names. Both branches surface the entry with"absent from every spec source (...)", then revert cleanly.Direct grep against the
-W erroroutput confirms each of the 7(model, field)keys no longer surfaces in any warning bullet:Market.response_price_units— gone fromtest_additive_drift[Market]Order.action— gone fromtest_additive_drift[Order](Fill.action,CreateOrderRequest.action,FillPayload.actionremain on the required side; those are separate keys, intentionally not in scope)CreateRFQRequest.target_cost_centi_cents— goneOrderbook.orderbook_fp— goneCreateRFQRequest.contracts_fp— gone (RfqDeletedPayload.contracts_fpremains on a different model)CreateOrderRequest.sell_position_floor— goneTickerPayload.time— gone (price_dollars,ts_msremain)Note on the failure count in acceptance criterion 2
The issue claimed "exactly 53 failures" under
-W error::UserWarning. The actual count is 57. The discrepancy is because the issue's math assumes 1 allowlist entry → 1 test fail→pass transition. In reality, only 3 tests have that property (test_additive_drift[Orderbook],[CreateRFQRequest],[CreateOrderRequest]— the three where the allowlisted field was the sole drift bullet). Market / Order / TickerPayload additive tests lose one bullet each but still warn because they retain many other unfiltered drift entries (fee_waiver_expiration_time,outcome_side,book_side,price_dollars,ts_ms, etc.).The substantive intent of the criterion — "Confirms the EXCLUSIONS map is being consulted by the response-side checks" — holds: the per-entry grep above shows all 7 keys are filtered while every other drift bullet is preserved.
Out of scope (per the issue)
warnings.warn(...)→pytest.fail(...)(#PR5).Part of the response-side spec-drift hardening stack tracked in #157.