From 5c61d8ae28d460e3ce4759080a2b3bbcb4b47dde Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Thu, 10 Sep 2026 00:31:55 -0400 Subject: [PATCH 1/4] feat(sports): compare MLB propositions and settlement rules (NRCL-99) --- docs/architecture/meta.json | 8 +- docs/architecture/sports-matching.mdx | 121 ++++++ examples/sports-matching-fixtures.json | 504 ++++++++++++++++++++++++ examples/sports_matching_demo.py | 28 ++ neural/sports.py | 264 +++++++++++++ tests/contracts/test_sports_matching.py | 158 ++++++++ 6 files changed, 1082 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/sports-matching.mdx create mode 100644 examples/sports-matching-fixtures.json create mode 100644 examples/sports_matching_demo.py create mode 100644 neural/sports.py create mode 100644 tests/contracts/test_sports_matching.py diff --git a/docs/architecture/meta.json b/docs/architecture/meta.json index 351187ee..5574dd6b 100644 --- a/docs/architecture/meta.json +++ b/docs/architecture/meta.json @@ -1,4 +1,10 @@ { "title": "Architecture", - "pages": ["start-here", "overview", "contracts", "stability"] + "pages": [ + "start-here", + "overview", + "contracts", + "sports-matching", + "stability" + ] } diff --git a/docs/architecture/sports-matching.mdx b/docs/architecture/sports-matching.mdx new file mode 100644 index 00000000..4eb7b789 --- /dev/null +++ b/docs/architecture/sports-matching.mdx @@ -0,0 +1,121 @@ +--- +title: Sports proposition matching +description: Compare MLB winner propositions while preserving settlement differences and unknown rules +--- + +`neural.sports` adds a versioned, offline contract for MLB team-winner markets. +It separates **the sporting proposition** from **the settlement policy**. +Matching teams and a game does not establish economically equivalent contracts. + +## API and existing consumers + +```python +from neural.sports import SportsMarket, compare_sports_markets + +left = SportsMarket.from_dict(left_payload) +right = SportsMarket.from_dict(right_payload) +comparison = compare_sports_markets(left, right) +print(comparison.to_dict()) +``` + +The output contains `status`, `proposition`, `settlement`, `differences`, and +`unknowns`. Each status is `compatible`, `different`, or `unknown`. Field names +identify the reason; the input contracts retain each rule value and its source +for display. The function makes no network calls or settlement decisions. + +This is additive to `StrategySpec` and `neural.contracts` v1. Their schemas, +hashes and replay behavior remain unchanged. Existing `NormalizedMarket` +consumers can retain `SportsMarket.to_dict()` under +`market.metadata["sports_proposition"]`; keep `market_id` equal to the native +identifier already used by the adapter. No broker abstraction or dependency is +introduced. Novig is accepted as a metadata venue, not as an executable adapter. + +## Version 1 identity + +Wire payloads require every documented dataclass field, reject extra fields, +and use `schema_version: "1.0.0"`. `sport`, `league`, and `market_type` are fixed +to `baseball`, `mlb`, and `winner`. Identity fields are: + +| Fields | Meaning | +| --- | --- | +| `venue`, `event_id`, `market_id`, `outcome_id` | Original venue and native identifiers; never replaced with canonical IDs | +| `raw_home_team_id`, `raw_away_team_id` | Original venue team IDs, or null when absent | +| `canonical_event_id`, `home_team_id`, `away_team_id` | Caller-resolved identities in a shared namespace; null when unresolved | +| `outcome_team_id` | The selected team-wins proposition, not a venue-specific yes/no label | +| `game_date` | Original official local schedule date (`YYYY-MM-DD`), retained after postponement | +| `game_number` | 1 or 2, explicitly established; null when unknown | +| `period` | `full_game` for the supported comparison; other named segments remain representable but excluded | + +The canonical event ID must identify one game, not a matchup or date bucket. +Use an authoritative common game identifier or an explicitly reviewed mapping. +An adapter must not invent mappings by lowercasing team names, translating an +unknown game number to 1, or truncating a UTC start timestamp into a local game +date. A rescheduled start time belongs in venue metadata; it does not silently +create a new canonical game. Home/away changes require mapping review. + +Every canonical field must agree and be known before settlement comparison. +Two missing values produce `unknown`. Game 1 and game 2 are different even if +an upstream mapper accidentally reuses the event ID. Opposite team outcomes +are different. Partial-game markets are excluded even when both periods agree. + +## Rule evidence and compatibility + +`SettlementRules` contains `terms` and `complete`. Each `RuleEvidence` contains: + +- `name`: one of `winner`, `extra_innings`, `forfeit`, `postponement`, + `cancellation`, `shortened_game`, `settlement_source`, `exceptional_payout`, + `venue_change`, or `replay`. +- `value`: a reviewed semantic policy identifier. Preserve material exceptions, + deadlines, source hierarchies, and payout procedures in its meaning. Raw text + similarity is not semantic equality. +- `source_url`, `source_sha256`, `retrieved_at`: HTTPS source, exact retrieved + content digest (null if unavailable), and timezone-aware observation time. + The digest is a content version, not a claimed publisher revision number. +- `scope`: `listed_contract`, `series`, `guidance`, or explicitly synthetic + `fixture`. + +`complete` defaults to false. Set it only after reviewing the listed contract +and applicable supplemental rules against every v1 dimension. If an exception +cannot be represented faithfully, leave review incomplete. Series PDFs and +general FAQs can surface documented differences, but cannot establish complete +listed-contract compatibility. A missing rule or snapshot also prevents it. + +For matching propositions, a known policy difference yields `different`, even +if other fields remain unknown; those unknowns are still returned. Equal rule +values require full source coverage to produce `compatible`. Source URLs need +not be identical across venues: compare reviewed meanings, retain both sources. +Synthetic rules can compare with synthetic rules, but cannot certify real +contract evidence. `compatible` describes these reviewed v1 dimensions; it is +neither a legal guarantee nor a risk-free arbitrage claim. + +## Offline fixture demonstration + +```bash +uv run python examples/sports_matching_demo.py +uv run pytest --no-cov tests/contracts/test_sports_matching.py +``` + +The JSON fixture uses synthetic game, team and native venue IDs throughout. +The ordinary-winner compatible pair uses identical **synthetic** rules. Other +cases demonstrate forfeit differences, postponed-game differences, doubleheader +separation, partial-game exclusion, missing rules and an unknown game number. +The demo tests matching and rule comparison, not game settlement or fill quality. + +Rule-reference cases retain source hashes observed September 10, 2026: + +- [Kalshi BASEBALLGAMEWIN terms](https://assets.kalshi.com/contract_terms/BASEBALLGAMEWIN.pdf) + distinguish forfeits before first pitch from those after play starts and use + a 48-hour postponement window. The fixture marks this as series evidence. +- [Polymarket US listed TB–ATL market](https://gateway.polymarket.us/v1/market/slug/aec-mlb-tb-atl-2026-09-10) + names MLB, includes extra innings, and specifies a rescheduling date within + two weeks or last fair market price. Its response hash includes mutable market + data. The fixture does not infer unlisted forfeit or shortened-game rules from + the general FAQ. +- [Novig contract directory](https://support.novig.com/en/articles/16083642-contracts) + links the MLB Winner Series PDF. Its rule-reference fixture records the linked + PDF URL without temporary access parameters and the PDF content hash. The + series voids a forfeit without an on-field result; postponement terms include + season-dependent windows and date-update exceptions. + +These are bounded policy examples, not verified market mappings or a complete +current rules database. The recorded inputs do not claim three-venue equivalence. diff --git a/examples/sports-matching-fixtures.json b/examples/sports-matching-fixtures.json new file mode 100644 index 00000000..03148d3e --- /dev/null +++ b/examples/sports-matching-fixtures.json @@ -0,0 +1,504 @@ +{ + "schema_version": "1.0.0", + "notice": "All event, team and venue IDs are synthetic. Real source rules illustrate policy differences; they do not establish listed cross-venue equivalence. The compatible case uses synthetic rules only.", + "synthetic_rule_source": { + "canonical_json": "{\"cancellation\":\"fair_value\",\"exceptional_payout\":\"fair_value\",\"extra_innings\":\"included\",\"forfeit\":\"official_awarded_winner\",\"postponement\":\"within_48_hours_of_original_start\",\"replay\":\"original_result\",\"settlement_source\":\"mlb_only\",\"shortened_game\":\"official_game_result\",\"venue_change\":\"contract_stands\",\"winner\":\"official_game_winner\"}", + "sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217" + }, + "markets": { + "synthetic_kalshi": { + "venue": "kalshi", + "event_id": "fixture:kalshi:event-1", + "market_id": "fixture:kalshi:winner-1", + "outcome_id": "fixture:kalshi:home-wins", + "raw_home_team_id": "fixture:kalshi:ATL", + "raw_away_team_id": "fixture:kalshi:TB", + "canonical_event_id": "fixture:mlb:2026-09-10:tb-atl:1", + "home_team_id": "mlb:atl", + "away_team_id": "mlb:tb", + "outcome_team_id": "mlb:atl", + "game_date": "2026-09-10", + "game_number": 1, + "period": "full_game", + "rules": { + "terms": [ + { + "name": "winner", + "value": "official_game_winner", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "extra_innings", + "value": "included", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "forfeit", + "value": "official_awarded_winner", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "postponement", + "value": "within_48_hours_of_original_start", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "cancellation", + "value": "fair_value", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "shortened_game", + "value": "official_game_result", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "settlement_source", + "value": "mlb_only", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "exceptional_payout", + "value": "fair_value", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "venue_change", + "value": "contract_stands", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "replay", + "value": "original_result", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + } + ], + "complete": true + }, + "schema_version": "1.0.0", + "sport": "baseball", + "league": "mlb", + "market_type": "winner" + }, + "synthetic_polymarket_us": { + "venue": "polymarket_us", + "event_id": "fixture:polymarket_us:event-1", + "market_id": "fixture:polymarket_us:winner-1", + "outcome_id": "fixture:polymarket_us:home-wins", + "raw_home_team_id": "fixture:polymarket_us:ATL", + "raw_away_team_id": "fixture:polymarket_us:TB", + "canonical_event_id": "fixture:mlb:2026-09-10:tb-atl:1", + "home_team_id": "mlb:atl", + "away_team_id": "mlb:tb", + "outcome_team_id": "mlb:atl", + "game_date": "2026-09-10", + "game_number": 1, + "period": "full_game", + "rules": { + "terms": [ + { + "name": "winner", + "value": "official_game_winner", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "extra_innings", + "value": "included", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "forfeit", + "value": "official_awarded_winner", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "postponement", + "value": "within_48_hours_of_original_start", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "cancellation", + "value": "fair_value", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "shortened_game", + "value": "official_game_result", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "settlement_source", + "value": "mlb_only", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "exceptional_payout", + "value": "fair_value", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "venue_change", + "value": "contract_stands", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + }, + { + "name": "replay", + "value": "original_result", + "source_url": "https://example.invalid/nrcl-99/synthetic-rules-v1.json", + "source_sha256": "1dbef9a76551f101a76bf16c0612594dc964ec81795d1b30ae5b3919f9706217", + "retrieved_at": "2026-09-10T00:00:00Z", + "scope": "fixture" + } + ], + "complete": true + }, + "schema_version": "1.0.0", + "sport": "baseball", + "league": "mlb", + "market_type": "winner" + }, + "kalshi_series_reference": { + "venue": "kalshi", + "event_id": "fixture:kalshi:event-1", + "market_id": "fixture:kalshi:winner-1", + "outcome_id": "fixture:kalshi:home-wins", + "raw_home_team_id": "fixture:kalshi:ATL", + "raw_away_team_id": "fixture:kalshi:TB", + "canonical_event_id": "fixture:mlb:2026-09-10:tb-atl:1", + "home_team_id": "mlb:atl", + "away_team_id": "mlb:tb", + "outcome_team_id": "mlb:atl", + "game_date": "2026-09-10", + "game_number": 1, + "period": "full_game", + "rules": { + "terms": [ + { + "name": "winner", + "value": "official_game_winner", + "source_url": "https://assets.kalshi.com/contract_terms/BASEBALLGAMEWIN.pdf", + "source_sha256": "46b02443153f4692acb3bac3d3aedabe93e837b08c80323013c8dce117ebb6e7", + "retrieved_at": "2026-09-10T04:25:59.570992+00:00", + "scope": "series" + }, + { + "name": "extra_innings", + "value": "included", + "source_url": "https://assets.kalshi.com/contract_terms/BASEBALLGAMEWIN.pdf", + "source_sha256": "46b02443153f4692acb3bac3d3aedabe93e837b08c80323013c8dce117ebb6e7", + "retrieved_at": "2026-09-10T04:25:59.570992+00:00", + "scope": "series" + }, + { + "name": "forfeit", + "value": "before_first_pitch_fair_value_after_start_official_awarded_winner", + "source_url": "https://assets.kalshi.com/contract_terms/BASEBALLGAMEWIN.pdf", + "source_sha256": "46b02443153f4692acb3bac3d3aedabe93e837b08c80323013c8dce117ebb6e7", + "retrieved_at": "2026-09-10T04:25:59.570992+00:00", + "scope": "series" + }, + { + "name": "postponement", + "value": "within_48_hours_of_original_start", + "source_url": "https://assets.kalshi.com/contract_terms/BASEBALLGAMEWIN.pdf", + "source_sha256": "46b02443153f4692acb3bac3d3aedabe93e837b08c80323013c8dce117ebb6e7", + "retrieved_at": "2026-09-10T04:25:59.570992+00:00", + "scope": "series" + }, + { + "name": "shortened_game", + "value": "official_game_result", + "source_url": "https://assets.kalshi.com/contract_terms/BASEBALLGAMEWIN.pdf", + "source_sha256": "46b02443153f4692acb3bac3d3aedabe93e837b08c80323013c8dce117ebb6e7", + "retrieved_at": "2026-09-10T04:25:59.570992+00:00", + "scope": "series" + }, + { + "name": "settlement_source", + "value": "league_then_espn_cbs_fox_ap_wsj_official_broadcaster", + "source_url": "https://assets.kalshi.com/contract_terms/BASEBALLGAMEWIN.pdf", + "source_sha256": "46b02443153f4692acb3bac3d3aedabe93e837b08c80323013c8dce117ebb6e7", + "retrieved_at": "2026-09-10T04:25:59.570992+00:00", + "scope": "series" + } + ], + "complete": false + }, + "schema_version": "1.0.0", + "sport": "baseball", + "league": "mlb", + "market_type": "winner" + }, + "polymarket_us_listing_reference": { + "venue": "polymarket_us", + "event_id": "fixture:polymarket_us:event-1", + "market_id": "fixture:polymarket_us:winner-1", + "outcome_id": "fixture:polymarket_us:home-wins", + "raw_home_team_id": "fixture:polymarket_us:ATL", + "raw_away_team_id": "fixture:polymarket_us:TB", + "canonical_event_id": "fixture:mlb:2026-09-10:tb-atl:1", + "home_team_id": "mlb:atl", + "away_team_id": "mlb:tb", + "outcome_team_id": "mlb:atl", + "game_date": "2026-09-10", + "game_number": 1, + "period": "full_game", + "rules": { + "terms": [ + { + "name": "winner", + "value": "official_game_winner", + "source_url": "https://gateway.polymarket.us/v1/market/slug/aec-mlb-tb-atl-2026-09-10", + "source_sha256": "7b5cc8e36072ab1a7c5b87347db949e1d19e9cb5fc8a4682c157dbef10aab899", + "retrieved_at": "2026-09-10T04:25:59.660275+00:00", + "scope": "listed_contract" + }, + { + "name": "extra_innings", + "value": "included", + "source_url": "https://gateway.polymarket.us/v1/market/slug/aec-mlb-tb-atl-2026-09-10", + "source_sha256": "7b5cc8e36072ab1a7c5b87347db949e1d19e9cb5fc8a4682c157dbef10aab899", + "retrieved_at": "2026-09-10T04:25:59.660275+00:00", + "scope": "listed_contract" + }, + { + "name": "postponement", + "value": "rescheduled_date_within_14_days_of_original_date", + "source_url": "https://gateway.polymarket.us/v1/market/slug/aec-mlb-tb-atl-2026-09-10", + "source_sha256": "7b5cc8e36072ab1a7c5b87347db949e1d19e9cb5fc8a4682c157dbef10aab899", + "retrieved_at": "2026-09-10T04:25:59.660275+00:00", + "scope": "listed_contract" + }, + { + "name": "settlement_source", + "value": "mlb_only", + "source_url": "https://gateway.polymarket.us/v1/market/slug/aec-mlb-tb-atl-2026-09-10", + "source_sha256": "7b5cc8e36072ab1a7c5b87347db949e1d19e9cb5fc8a4682c157dbef10aab899", + "retrieved_at": "2026-09-10T04:25:59.660275+00:00", + "scope": "listed_contract" + }, + { + "name": "exceptional_payout", + "value": "last_fair_market_price", + "source_url": "https://gateway.polymarket.us/v1/market/slug/aec-mlb-tb-atl-2026-09-10", + "source_sha256": "7b5cc8e36072ab1a7c5b87347db949e1d19e9cb5fc8a4682c157dbef10aab899", + "retrieved_at": "2026-09-10T04:25:59.660275+00:00", + "scope": "listed_contract" + } + ], + "complete": false + }, + "schema_version": "1.0.0", + "sport": "baseball", + "league": "mlb", + "market_type": "winner" + }, + "novig_series_reference": { + "venue": "novig", + "event_id": "fixture:novig:event-1", + "market_id": "fixture:novig:winner-1", + "outcome_id": "fixture:novig:home-wins", + "raw_home_team_id": "fixture:novig:ATL", + "raw_away_team_id": "fixture:novig:TB", + "canonical_event_id": "fixture:mlb:2026-09-10:tb-atl:1", + "home_team_id": "mlb:atl", + "away_team_id": "mlb:tb", + "outcome_team_id": "mlb:atl", + "game_date": "2026-09-10", + "game_number": 1, + "period": "full_game", + "rules": { + "terms": [ + { + "name": "winner", + "value": "official_game_winner", + "source_url": "https://novig.intercom-attachments-1.com/i/o/y5ui11zw/2574336132/b47910be4d7b2e11263be08e7901/MLB+Winner+Series.pdf", + "source_sha256": "a60394032c519301b91a452c998ef4e77c5153ff92e4cee29d6d3d949b30b7dc", + "retrieved_at": "2026-09-10T04:25:59.920332+00:00", + "scope": "series" + }, + { + "name": "forfeit", + "value": "without_on_field_result_void", + "source_url": "https://novig.intercom-attachments-1.com/i/o/y5ui11zw/2574336132/b47910be4d7b2e11263be08e7901/MLB+Winner+Series.pdf", + "source_sha256": "a60394032c519301b91a452c998ef4e77c5153ff92e4cee29d6d3d949b30b7dc", + "retrieved_at": "2026-09-10T04:25:59.920332+00:00", + "scope": "series" + }, + { + "name": "postponement", + "value": "48_hours_regular_season_45_days_postseason_subject_to_date_updates", + "source_url": "https://novig.intercom-attachments-1.com/i/o/y5ui11zw/2574336132/b47910be4d7b2e11263be08e7901/MLB+Winner+Series.pdf", + "source_sha256": "a60394032c519301b91a452c998ef4e77c5153ff92e4cee29d6d3d949b30b7dc", + "retrieved_at": "2026-09-10T04:25:59.920332+00:00", + "scope": "series" + }, + { + "name": "exceptional_payout", + "value": "void_fair_value_process", + "source_url": "https://novig.intercom-attachments-1.com/i/o/y5ui11zw/2574336132/b47910be4d7b2e11263be08e7901/MLB+Winner+Series.pdf", + "source_sha256": "a60394032c519301b91a452c998ef4e77c5153ff92e4cee29d6d3d949b30b7dc", + "retrieved_at": "2026-09-10T04:25:59.920332+00:00", + "scope": "series" + } + ], + "complete": false + }, + "schema_version": "1.0.0", + "sport": "baseball", + "league": "mlb", + "market_type": "winner" + }, + "unknown_rules": { + "venue": "novig", + "event_id": "fixture:novig:event-1", + "market_id": "fixture:novig:winner-1", + "outcome_id": "fixture:novig:home-wins", + "raw_home_team_id": "fixture:novig:ATL", + "raw_away_team_id": "fixture:novig:TB", + "canonical_event_id": "fixture:mlb:2026-09-10:tb-atl:1", + "home_team_id": "mlb:atl", + "away_team_id": "mlb:tb", + "outcome_team_id": "mlb:atl", + "game_date": "2026-09-10", + "game_number": 1, + "period": "full_game", + "rules": { + "terms": [], + "complete": false + }, + "schema_version": "1.0.0", + "sport": "baseball", + "league": "mlb", + "market_type": "winner" + } + }, + "cases": [ + { + "name": "ordinary_winner", + "left": "synthetic_kalshi", + "right": "synthetic_polymarket_us", + "right_overrides": {}, + "expected": "compatible", + "expected_proposition": "compatible", + "expected_field": null + }, + { + "name": "forfeit_without_on_field_result", + "left": "kalshi_series_reference", + "right": "novig_series_reference", + "right_overrides": {}, + "expected": "different", + "expected_proposition": "compatible", + "expected_field": "rules:forfeit" + }, + { + "name": "postponed_game", + "left": "kalshi_series_reference", + "right": "polymarket_us_listing_reference", + "right_overrides": {}, + "expected": "different", + "expected_proposition": "compatible", + "expected_field": "rules:postponement" + }, + { + "name": "doubleheader_game_two", + "left": "synthetic_kalshi", + "right": "synthetic_polymarket_us", + "right_overrides": { + "game_number": 2 + }, + "expected": "different", + "expected_proposition": "different", + "expected_field": "game_number" + }, + { + "name": "partial_game_excluded", + "left": "synthetic_kalshi", + "right": "synthetic_polymarket_us", + "right_overrides": { + "period": "first_5_innings" + }, + "expected": "different", + "expected_proposition": "different", + "expected_field": "scope:full_game_only" + }, + { + "name": "missing_rules", + "left": "synthetic_kalshi", + "right": "unknown_rules", + "right_overrides": {}, + "expected": "unknown", + "expected_proposition": "compatible", + "expected_field": "rules:incomplete_review" + }, + { + "name": "missing_game_number", + "left": "synthetic_kalshi", + "right": "synthetic_polymarket_us", + "right_overrides": { + "game_number": null + }, + "expected": "unknown", + "expected_proposition": "unknown", + "expected_field": "game_number" + } + ] +} diff --git a/examples/sports_matching_demo.py b/examples/sports_matching_demo.py new file mode 100644 index 00000000..d985287c --- /dev/null +++ b/examples/sports_matching_demo.py @@ -0,0 +1,28 @@ +"""Offline MLB proposition/rule comparison using explicitly synthetic identities. + +Run from the repository: uv run python examples/sports_matching_demo.py +No network, credentials, orders, fee assumptions or settlement simulation. +""" + +import json +from pathlib import Path + +from neural.sports import SportsMarket, compare_sports_markets + + +def main() -> None: + fixture = json.loads(Path(__file__).with_name("sports-matching-fixtures.json").read_text()) + print(fixture["notice"]) + for case in fixture["cases"]: + left = SportsMarket.from_dict(fixture["markets"][case["left"]]) + right = SportsMarket.from_dict( + {**fixture["markets"][case["right"]], **case["right_overrides"]} + ) + result = compare_sports_markets(left, right) + assert result.status == case["expected"], case["name"] + assert result.proposition == case["expected_proposition"], case["name"] + print(json.dumps({"case": case["name"], **result.to_dict()}, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/neural/sports.py b/neural/sports.py new file mode 100644 index 00000000..cf619e9a --- /dev/null +++ b/neural/sports.py @@ -0,0 +1,264 @@ +"""Versioned MLB winner propositions and evidence-aware rule comparison (NRCL-99). + +Pure data: no discovery, fuzzy team matching, settlement, or trading authority. +The existing NormalizedMarket.metadata can carry SportsMarket.to_dict(). +""" + +from __future__ import annotations + +import re +from dataclasses import asdict, dataclass, fields +from datetime import date, datetime +from typing import Any, Literal +from urllib.parse import urlsplit + +SPORTS_VERSION = "1.0.0" +RULE_FIELDS = ( + "winner", + "extra_innings", + "forfeit", + "postponement", + "cancellation", + "shortened_game", + "settlement_source", + "exceptional_payout", + "venue_change", + "replay", +) +Status = Literal["compatible", "different", "unknown"] + + +def _text(value: Any, name: str, *, optional: bool = False) -> None: + if optional and value is None: + return + if not isinstance(value, str) or not value or value != value.strip() or len(value) > 2048: + raise ValueError(f"{name}: expected nonempty text without surrounding whitespace") + if any(ord(char) < 32 for char in value): + raise ValueError(f"{name}: control characters are forbidden") + + +def _wire(cls: Any, payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict) or set(payload) != {field.name for field in fields(cls)}: + raise ValueError(f"{cls.__name__}: expected exactly the documented fields") + return dict(payload) + + +@dataclass(frozen=True) +class RuleEvidence: + """A reviewed policy identifier, with retained source provenance. + + Values are semantic identifiers assigned by the source reviewer, not raw + prose or an automatic interpretation. Equal strings alone do not prove law. + """ + + name: str + value: str + source_url: str + source_sha256: str | None + retrieved_at: str + scope: str + + def __post_init__(self) -> None: + if self.name not in RULE_FIELDS: + raise ValueError("unsupported settlement rule field") + for name in ("value", "source_url", "retrieved_at"): + _text(getattr(self, name), name) + url = urlsplit(self.source_url) + if url.scheme != "https" or not url.hostname or url.username or url.password: + raise ValueError("rule source must be an HTTPS URL without credentials") + if self.source_sha256 is not None and ( + not isinstance(self.source_sha256, str) + or re.fullmatch(r"[0-9a-f]{64}", self.source_sha256) is None + ): + raise ValueError("source_sha256 must be a lowercase SHA-256 or null") + try: + observed = datetime.fromisoformat(self.retrieved_at.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("retrieved_at must be an ISO timestamp with timezone") from exc + if observed.tzinfo is None or observed.utcoffset() is None: + raise ValueError("retrieved_at must be timezone-aware") + if self.scope not in ("listed_contract", "series", "guidance", "fixture"): + raise ValueError("unsupported rule source scope") + + +@dataclass(frozen=True) +class SettlementRules: + terms: tuple[RuleEvidence, ...] = () + complete: bool = False + + def __post_init__(self) -> None: + if type(self.complete) is not bool: + raise ValueError("complete must be a boolean") + if not isinstance(self.terms, tuple) or any( + not isinstance(term, RuleEvidence) for term in self.terms + ): + raise ValueError("terms must be a tuple of RuleEvidence") + if len({term.name for term in self.terms}) != len(self.terms): + raise ValueError("duplicate settlement rule field") + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> SettlementRules: + wire = _wire(cls, payload) + if not isinstance(wire["terms"], list): + raise ValueError("terms must be an array") + wire["terms"] = tuple(RuleEvidence(**_wire(RuleEvidence, item)) for item in wire["terms"]) + return cls(**wire) + + +@dataclass(frozen=True) +class SportsMarket: + """One selected team-wins outcome; canonical mapping is caller-owned. + + game_date is the original official local schedule date, not a UTC date + derived from a timestamp. A postponed game retains its canonical identity. + Unknown canonical fields remain null. Raw venue identifiers are preserved. + """ + + venue: str + event_id: str + market_id: str + outcome_id: str + raw_home_team_id: str | None + raw_away_team_id: str | None + canonical_event_id: str | None + home_team_id: str | None + away_team_id: str | None + outcome_team_id: str | None + game_date: str | None + game_number: int | None + period: str | None + rules: SettlementRules + schema_version: str = SPORTS_VERSION + sport: str = "baseball" + league: str = "mlb" + market_type: str = "winner" + + def __post_init__(self) -> None: + if self.schema_version != SPORTS_VERSION: + raise ValueError("unsupported sports contract version") + if (self.sport, self.league, self.market_type) != ("baseball", "mlb", "winner"): + raise ValueError("v1 supports MLB winner propositions only") + if self.venue not in ("kalshi", "polymarket_us", "novig"): + raise ValueError("unsupported U.S. venue") + for name in ("event_id", "market_id", "outcome_id"): + _text(getattr(self, name), name) + for name in ( + "raw_home_team_id", + "raw_away_team_id", + "canonical_event_id", + "home_team_id", + "away_team_id", + "outcome_team_id", + "period", + ): + _text(getattr(self, name), name, optional=True) + if self.home_team_id is not None and self.home_team_id == self.away_team_id: + raise ValueError("home and away teams must differ") + if self.outcome_team_id is not None and self.outcome_team_id not in ( + self.home_team_id, + self.away_team_id, + ): + raise ValueError("selected outcome must identify a mapped participant") + if self.game_number is not None and ( + type(self.game_number) is not int or self.game_number not in (1, 2) + ): + raise ValueError("game_number must be 1, 2, or null; never infer missing game 1") + if self.game_date is not None: + if not isinstance(self.game_date, str) or not re.fullmatch( + r"\d{4}-\d{2}-\d{2}", self.game_date + ): + raise ValueError("game_date must be an ISO local date or null") + date.fromisoformat(self.game_date) + if not isinstance(self.rules, SettlementRules): + raise ValueError("rules must be SettlementRules") + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> SportsMarket: + wire = _wire(cls, payload) + wire["rules"] = SettlementRules.from_dict(wire["rules"]) + return cls(**wire) + + def to_dict(self) -> dict[str, Any]: + wire = asdict(self) + wire["rules"]["terms"] = [asdict(term) for term in self.rules.terms] + return wire + + +@dataclass(frozen=True) +class SportsComparison: + proposition: Status + settlement: Status + differences: tuple[str, ...] + unknowns: tuple[str, ...] + + @property + def status(self) -> Status: + if "different" in (self.proposition, self.settlement): + return "different" + if "unknown" in (self.proposition, self.settlement): + return "unknown" + return "compatible" + + def to_dict(self) -> dict[str, Any]: + return { + "status": self.status, + "proposition": self.proposition, + "settlement": self.settlement, + "differences": list(self.differences), + "unknowns": list(self.unknowns), + } + + +def compare_sports_markets(left: SportsMarket, right: SportsMarket) -> SportsComparison: + """Compare the reviewed v1 dimensions, never infer missing facts as equal. + + Settlement is compared only after canonical full-game propositions match. + A known policy difference wins over other unknown rule fields. Compatibility + requires complete reviewer coverage, listed-contract sources and snapshots. + Synthetic fixtures can compare with fixtures, never real contract sources. + """ + differences: list[str] = [] + unknowns: list[str] = [] + for name in ( + "canonical_event_id", + "home_team_id", + "away_team_id", + "outcome_team_id", + "game_date", + "game_number", + "period", + ): + a, b = getattr(left, name), getattr(right, name) + if a is None or b is None: + unknowns.append(name) + elif a != b: + differences.append(name) + if any(market.period not in (None, "full_game") for market in (left, right)): + differences.append("scope:full_game_only") + proposition: Status = "different" if differences else "unknown" if unknowns else "compatible" + if proposition != "compatible": + return SportsComparison( + proposition, "unknown", tuple(differences), tuple(unknowns + ["rules:not_compared"]) + ) + + left_rules = {term.name: term for term in left.rules.terms} + right_rules = {term.name: term for term in right.rules.terms} + for name in RULE_FIELDS: + a_rule, b_rule = left_rules.get(name), right_rules.get(name) + if a_rule is None or b_rule is None: + unknowns.append("rules:" + name) + continue + if "fixture" in (a_rule.scope, b_rule.scope) and a_rule.scope != b_rule.scope: + unknowns.append("rules:" + name + ":mixed_fixture") + continue + if a_rule.value != b_rule.value: + differences.append("rules:" + name) + if any( + rule.scope not in ("listed_contract", "fixture") or rule.source_sha256 is None + for rule in (a_rule, b_rule) + ): + unknowns.append("rules:" + name + ":provenance") + if not left.rules.complete or not right.rules.complete: + unknowns.append("rules:incomplete_review") + settlement: Status = "different" if differences else "unknown" if unknowns else "compatible" + return SportsComparison(proposition, settlement, tuple(differences), tuple(unknowns)) diff --git a/tests/contracts/test_sports_matching.py b/tests/contracts/test_sports_matching.py new file mode 100644 index 00000000..8e04e726 --- /dev/null +++ b/tests/contracts/test_sports_matching.py @@ -0,0 +1,158 @@ +"""NRCL-99: identities, rule provenance and false-equivalence boundaries.""" + +import hashlib +import json +from dataclasses import replace +from pathlib import Path + +import pytest + +from neural.sports import RuleEvidence, SettlementRules, SportsMarket, compare_sports_markets + +FIXTURE = json.loads( + (Path(__file__).parents[2] / "examples/sports-matching-fixtures.json").read_text() +) + + +def market(name="synthetic_kalshi"): + return SportsMarket.from_dict(FIXTURE["markets"][name]) + + +@pytest.mark.parametrize("case", FIXTURE["cases"], ids=lambda case: case["name"]) +def test_customer_comparison_cases(case): + left = market(case["left"]) + right = SportsMarket.from_dict({**FIXTURE["markets"][case["right"]], **case["right_overrides"]}) + result = compare_sports_markets(left, right) + assert result.status == case["expected"] + assert result.proposition == case["expected_proposition"] + if case["expected_field"]: + assert case["expected_field"] in (*result.differences, *result.unknowns) + assert compare_sports_markets(right, left) == result + if result.proposition != "compatible": + assert result.settlement == "unknown" + assert not any(field.startswith("rules:") for field in result.differences) + + +def test_wire_round_trip_retains_raw_ids_and_rule_provenance(): + for payload in FIXTURE["markets"].values(): + parsed = SportsMarket.from_dict(payload) + assert parsed.to_dict() == payload + assert SportsMarket.from_dict(json.loads(json.dumps(parsed.to_dict()))) == parsed + source = FIXTURE["synthetic_rule_source"] + assert hashlib.sha256(source["canonical_json"].encode()).hexdigest() == source["sha256"] + assert {term.source_sha256 for term in market().rules.terms} == {source["sha256"]} + + +@pytest.mark.parametrize( + "field", + [ + "canonical_event_id", + "home_team_id", + "away_team_id", + "outcome_team_id", + "game_date", + "game_number", + "period", + ], +) +def test_missing_identity_on_both_sides_is_unknown(field): + original = market() + changes = {field: None} + if field == "home_team_id": + changes["outcome_team_id"] = None + missing = replace(original, **changes) + result = compare_sports_markets(missing, missing) + assert result.status == "unknown" + assert field in result.unknowns + + +def test_outcome_and_event_identity_cannot_match_by_team_names_only(): + original = market() + for changed in ( + replace(original, outcome_team_id=original.away_team_id), + replace(original, canonical_event_id="fixture:other-event"), + replace(original, game_date="2026-09-11"), + replace(original, period="first_5_innings"), + ): + assert compare_sports_markets(original, changed).status == "different" + partial = replace(original, period="first_5_innings") + assert compare_sports_markets(partial, partial).status == "different" + + +@pytest.mark.parametrize("scope", ["series", "guidance"]) +def test_matching_policy_values_with_partial_source_scope_stay_unknown(scope): + original = market() + rules = SettlementRules( + tuple(replace(term, scope=scope) for term in original.rules.terms), True + ) + candidate = replace(original, rules=rules) + assert compare_sports_markets(candidate, candidate).status == "unknown" + + +def test_missing_rule_hash_and_incomplete_review_cannot_claim_compatibility(): + original = market() + variants = [ + SettlementRules(original.rules.terms, False), + SettlementRules(original.rules.terms[:-1], True), + SettlementRules( + tuple(replace(term, source_sha256=None) for term in original.rules.terms), True + ), + ] + for rules in variants: + candidate = replace(original, rules=rules) + assert compare_sports_markets(candidate, candidate).status == "unknown" + real = replace( + original, + rules=SettlementRules( + tuple(replace(term, scope="listed_contract") for term in original.rules.terms), True + ), + ) + assert compare_sports_markets(original, real).status == "unknown" + + +@pytest.mark.parametrize( + "field,value", + [ + ("schema_version", "2.0.0"), + ("league", "nba"), + ("venue", "polymarket"), + ("market_type", "spread"), + ("game_number", True), + ("game_number", 0), + ("game_number", "1"), + ("game_date", "2026-09-10T00:00:00Z"), + ("game_date", "2026-02-30"), + ("outcome_team_id", "mlb:other"), + ("market_id", ""), + ("canonical_event_id", " event "), + ], +) +def test_invalid_wire_identity_fails(field, value): + payload = market().to_dict() + payload[field] = value + with pytest.raises(ValueError): + SportsMarket.from_dict(payload) + + +def test_strict_nested_wire_and_rule_evidence(): + payload = market().to_dict() + with pytest.raises(ValueError): + SportsMarket.from_dict({**payload, "unknown": True}) + with pytest.raises(ValueError): + SettlementRules.from_dict({"terms": [], "complete": "true"}) + with pytest.raises(ValueError): + SettlementRules((market().rules.terms[0], market().rules.terms[0]), True) + evidence = payload["rules"]["terms"][0] + for change in ( + {"name": "unmodeled_rule"}, + {"source_sha256": "not-a-hash"}, + {"retrieved_at": "2026-09-10T00:00:00"}, + {"source_url": "http://example.com"}, + {"source_url": "https://user:password@example.com"}, + {"value": ""}, + ): + with pytest.raises(ValueError): + RuleEvidence(**{**evidence, **change}) + payload["rules"]["terms"][0]["extra"] = "rejected" + with pytest.raises(ValueError): + SportsMarket.from_dict(payload) From ded734c6cbfecb57abd60c8e88be570f86d27a8b Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Thu, 10 Sep 2026 00:51:46 -0400 Subject: [PATCH 2/4] fix(sports): enforce fixture and wire provenance boundaries --- docs/architecture/sports-matching.mdx | 8 +++- neural/sports.py | 31 ++++++++++++- tests/contracts/test_sports_matching.py | 60 +++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/docs/architecture/sports-matching.mdx b/docs/architecture/sports-matching.mdx index 4eb7b789..4d0b93ab 100644 --- a/docs/architecture/sports-matching.mdx +++ b/docs/architecture/sports-matching.mdx @@ -84,8 +84,12 @@ For matching propositions, a known policy difference yields `different`, even if other fields remain unknown; those unknowns are still returned. Equal rule values require full source coverage to produce `compatible`. Source URLs need not be identical across venues: compare reviewed meanings, retain both sources. -Synthetic rules can compare with synthetic rules, but cannot certify real -contract evidence. `compatible` describes these reviewed v1 dimensions; it is +Synthetic rules require `fixture:`-prefixed native event, market and outcome +IDs, plus a `fixture:`-prefixed canonical event ID when known. Copying fixture +rules onto real market identities is rejected. Synthetic rules can compare +with synthetic rules, but cannot certify real contract evidence. Source URLs +must have valid HTTPS hostnames and ports; wire text must encode as UTF-8. +`compatible` describes these reviewed v1 dimensions; it is neither a legal guarantee nor a risk-free arbitrage claim. ## Offline fixture demonstration diff --git a/neural/sports.py b/neural/sports.py index cf619e9a..94a3c983 100644 --- a/neural/sports.py +++ b/neural/sports.py @@ -9,6 +9,7 @@ import re from dataclasses import asdict, dataclass, fields from datetime import date, datetime +from ipaddress import IPv6Address from typing import Any, Literal from urllib.parse import urlsplit @@ -35,6 +36,10 @@ def _text(value: Any, name: str, *, optional: bool = False) -> None: raise ValueError(f"{name}: expected nonempty text without surrounding whitespace") if any(ord(char) < 32 for char in value): raise ValueError(f"{name}: control characters are forbidden") + try: + value.encode("utf-8") + except UnicodeEncodeError as exc: + raise ValueError(f"{name}: text must be valid UTF-8") from exc def _wire(cls: Any, payload: Any) -> dict[str, Any]: @@ -64,8 +69,27 @@ def __post_init__(self) -> None: for name in ("value", "source_url", "retrieved_at"): _text(getattr(self, name), name) url = urlsplit(self.source_url) - if url.scheme != "https" or not url.hostname or url.username or url.password: + if ( + url.scheme != "https" + or not url.hostname + or url.username is not None + or url.password is not None + ): raise ValueError("rule source must be an HTTPS URL without credentials") + try: + port = url.port # Reject nonnumeric and out-of-range ports. + if url.netloc.endswith(":") or (port is not None and port < 1): + raise ValueError("empty or unusable port") + host = url.hostname.encode("idna").decode("ascii") + if ":" in host: + IPv6Address(host) + else: + host = host.removesuffix(".") + label = r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?" + if len(host) > 253 or re.fullmatch(rf"{label}(?:\.{label})*", host) is None: + raise ValueError("malformed hostname") + except (ValueError, UnicodeError) as exc: + raise ValueError("rule source must have a valid hostname and port") from exc if self.source_sha256 is not None and ( not isinstance(self.source_sha256, str) or re.fullmatch(r"[0-9a-f]{64}", self.source_sha256) is None @@ -171,6 +195,11 @@ def __post_init__(self) -> None: date.fromisoformat(self.game_date) if not isinstance(self.rules, SettlementRules): raise ValueError("rules must be SettlementRules") + if any(term.scope == "fixture" for term in self.rules.terms): + for name in ("event_id", "market_id", "outcome_id", "canonical_event_id"): + value = getattr(self, name) + if value is not None and not value.startswith("fixture:"): + raise ValueError(f"{name}: fixture rules require fixture-prefixed identities") @classmethod def from_dict(cls, payload: dict[str, Any]) -> SportsMarket: diff --git a/tests/contracts/test_sports_matching.py b/tests/contracts/test_sports_matching.py index 8e04e726..1a3df0fa 100644 --- a/tests/contracts/test_sports_matching.py +++ b/tests/contracts/test_sports_matching.py @@ -110,6 +110,66 @@ def test_missing_rule_hash_and_incomplete_review_cannot_claim_compatibility(): assert compare_sports_markets(original, real).status == "unknown" +@pytest.mark.parametrize("field", ["event_id", "market_id", "outcome_id", "canonical_event_id"]) +def test_fixture_rules_cannot_be_copied_onto_real_market_identities(field): + payload = market().to_dict() + payload[field] = "mlb:real-market-identity" + with pytest.raises(ValueError, match="fixture-prefixed"): + SportsMarket.from_dict(payload) + + +@pytest.mark.parametrize( + "source_url", + [ + "https://exa mple.com/path", + "https://./x", + "https://example.com:notaport/path", + "https://example.com:65536/path", + "https://example.com:/path", + "https://-example.com/path", + "https://example..com/path", + "https://@example.com/path", + ], +) +def test_malformed_source_authorities_are_rejected(source_url): + with pytest.raises(ValueError): + replace(market().rules.terms[0], source_url=source_url) + + +@pytest.mark.parametrize( + "source_url", + [ + "https://example.com:443/path", + "https://example.com./path", + "https://[2001:db8::1]/path", + "https://bücher.example/path", + ], +) +def test_valid_source_authorities_remain_supported(source_url): + assert replace(market().rules.terms[0], source_url=source_url).source_url == source_url + + +def test_lone_surrogates_cannot_escape_the_wire_boundary(): + for field in ( + "event_id", + "market_id", + "outcome_id", + "raw_home_team_id", + "raw_away_team_id", + "canonical_event_id", + "home_team_id", + "away_team_id", + "outcome_team_id", + "period", + ): + for surrogate in ("\ud800", "\udfff"): + with pytest.raises(ValueError, match="UTF-8"): + replace(market(), **{field: "fixture:" + surrogate}) + for field in ("value", "source_url", "retrieved_at"): + with pytest.raises(ValueError, match="UTF-8"): + replace(market().rules.terms[0], **{field: "source\ud800"}) + + @pytest.mark.parametrize( "field,value", [ From 391b7f583b8bc0be6d43fd80c16939ed5aa6afb0 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Thu, 10 Sep 2026 01:21:18 -0400 Subject: [PATCH 3/4] fix(sports): validate bracketed source hosts as IPv6 --- neural/sports.py | 2 +- tests/contracts/test_sports_matching.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/neural/sports.py b/neural/sports.py index 94a3c983..c837030e 100644 --- a/neural/sports.py +++ b/neural/sports.py @@ -81,7 +81,7 @@ def __post_init__(self) -> None: if url.netloc.endswith(":") or (port is not None and port < 1): raise ValueError("empty or unusable port") host = url.hostname.encode("idna").decode("ascii") - if ":" in host: + if url.netloc.startswith("["): IPv6Address(host) else: host = host.removesuffix(".") diff --git a/tests/contracts/test_sports_matching.py b/tests/contracts/test_sports_matching.py index 1a3df0fa..6147b04d 100644 --- a/tests/contracts/test_sports_matching.py +++ b/tests/contracts/test_sports_matching.py @@ -129,6 +129,7 @@ def test_fixture_rules_cannot_be_copied_onto_real_market_identities(field): "https://-example.com/path", "https://example..com/path", "https://@example.com/path", + "https://[v1.foo]/rules", ], ) def test_malformed_source_authorities_are_rejected(source_url): From a7ee8f08f017e0ff3a31bdfe7b4d163ce0d8d64c Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Thu, 10 Sep 2026 01:32:05 -0400 Subject: [PATCH 4/4] fix(sports): reject scoped IPv6 rule sources --- docs/architecture/sports-matching.mdx | 3 ++- neural/sports.py | 2 ++ tests/contracts/test_sports_matching.py | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/architecture/sports-matching.mdx b/docs/architecture/sports-matching.mdx index 4d0b93ab..726813e7 100644 --- a/docs/architecture/sports-matching.mdx +++ b/docs/architecture/sports-matching.mdx @@ -88,7 +88,8 @@ Synthetic rules require `fixture:`-prefixed native event, market and outcome IDs, plus a `fixture:`-prefixed canonical event ID when known. Copying fixture rules onto real market identities is rejected. Synthetic rules can compare with synthetic rules, but cannot certify real contract evidence. Source URLs -must have valid HTTPS hostnames and ports; wire text must encode as UTF-8. +must have valid HTTPS DNS names or unscoped IPv6 hosts and valid ports. IPv6 +zone identifiers are unsupported for rule sources. Wire text must encode as UTF-8. `compatible` describes these reviewed v1 dimensions; it is neither a legal guarantee nor a risk-free arbitrage claim. diff --git a/neural/sports.py b/neural/sports.py index c837030e..ad0c04ff 100644 --- a/neural/sports.py +++ b/neural/sports.py @@ -82,6 +82,8 @@ def __post_init__(self) -> None: raise ValueError("empty or unusable port") host = url.hostname.encode("idna").decode("ascii") if url.netloc.startswith("["): + if "%" in host: + raise ValueError("IPv6 zone identifiers are not supported for rule sources") IPv6Address(host) else: host = host.removesuffix(".") diff --git a/tests/contracts/test_sports_matching.py b/tests/contracts/test_sports_matching.py index 6147b04d..aac6d963 100644 --- a/tests/contracts/test_sports_matching.py +++ b/tests/contracts/test_sports_matching.py @@ -130,6 +130,8 @@ def test_fixture_rules_cannot_be_copied_onto_real_market_identities(field): "https://example..com/path", "https://@example.com/path", "https://[v1.foo]/rules", + "https://[fe80::1%bad zone]/rules", + "https://[fe80::1%25en0]/rules", ], ) def test_malformed_source_authorities_are_rejected(source_url):