diff --git a/.github/workflows/schema-drift.yml b/.github/workflows/schema-drift.yml index 72512c45..473a3bf0 100644 --- a/.github/workflows/schema-drift.yml +++ b/.github/workflows/schema-drift.yml @@ -18,13 +18,17 @@ on: - "packages/core/src/mostlyright/_internal/merge/**" - "packages/core/src/mostlyright/international.py" - "packages/core/src/mostlyright/qc.py" + - "packages/core/src/mostlyright/registry/**" + - "packages/core/src/mostlyright/contracts/**" - "packages/markets/src/mostlyright/markets/catalog/kalshi_stations.py" - "packages/markets/src/mostlyright/markets/_per_event_station.py" - "packages/markets/src/mostlyright/markets/polymarket_city_stations.json" + - "packages/weather/src/mostlyright/weather/**" - "scripts/export_schemas.py" - "schemas/**" - "packages-ts/codegen/**" - "packages-ts/*/src/**/generated/**" + - "packages-ts/weather/catalog.json" - ".github/workflows/schema-drift.yml" pull_request: paths: @@ -34,13 +38,17 @@ on: - "packages/core/src/mostlyright/_internal/merge/**" - "packages/core/src/mostlyright/international.py" - "packages/core/src/mostlyright/qc.py" + - "packages/core/src/mostlyright/registry/**" + - "packages/core/src/mostlyright/contracts/**" - "packages/markets/src/mostlyright/markets/catalog/kalshi_stations.py" - "packages/markets/src/mostlyright/markets/_per_event_station.py" - "packages/markets/src/mostlyright/markets/polymarket_city_stations.json" + - "packages/weather/src/mostlyright/weather/**" - "scripts/export_schemas.py" - "schemas/**" - "packages-ts/codegen/**" - "packages-ts/*/src/**/generated/**" + - "packages-ts/weather/catalog.json" - ".github/workflows/schema-drift.yml" jobs: @@ -80,7 +88,7 @@ jobs: - name: Check for schema or codegen drift run: | - if ! git diff --exit-code schemas/ packages-ts/*/src/**/generated/; then + if ! git diff --exit-code schemas/ packages-ts/*/src/**/generated/ packages-ts/weather/catalog.json; then echo "::error::Schema or codegen drift detected. Run 'uv run python scripts/export_schemas.py && pnpm codegen' locally and commit the result." exit 1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md index a93eaec2..4f3c6688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,106 @@ All notable changes to `mostlyright`. The format follows [Keep a Changelog](http ## [Unreleased] +## [1.17.0] — 2026-07-14 — Registry API redesign: the `align` / `spine` / `sources` ladder + per-domain `pairs()` + +Minor release, dual-SDK (PyPI 1.17.0 + npm 1.17.0 in lockstep). Phase 34 answers +the researcher feedback on the shipped composer ("too much magic — I can't tell a +feature from the target at a call site; let me build the DataFrame myself") by +splitting `dataset()`'s four fused concerns into **two orthogonal axes + one +composition operator**: SOURCES (`mr..(entity, window)` → X), +LABELS (`mr..label.(entity, window)` → a spine with `y`), and +**`mr.align(spine, *sources)`** — a leakage-guarded as-of join frozen at two +concepts. Every intermediate product is a plain, inspectable `pd.DataFrame`. + +**This release is additive-plus-shims. Nothing you run today breaks** — the +default `research()` / `dataset()` output is byte-identical to 1.16 (live parity +gate 5/5 byte-green), every relocated symbol keeps an importable +`DeprecationWarning` shim, and **all 2.0 removals are DOCUMENTED only** (no shim +removed this release; ≥2 minors of warning runway). + +Migration guide (where-did-each-kwarg-go table, the ladder, the symbol-move map, +TypeScript-on-1.17, econ-unchanged): [`docs/migration/v117-align-spine-sources.md`](docs/migration/v117-align-spine-sources.md). + +### Added + +- **`mr.align(spine, *sources)` — the frozen composition operator (D-34.01..05).** + A pure collapse + as-of/equality join + **per-source leakage audit** so a + raised `LeakageError` names WHICH source leaked and never blames a clean + co-aligned source. Signature is exactly `(spine, *sources)` — all policy + (revision authority, `pit_fidelity`, staleness) lives on the source specs. The + bitemporal `SourceContract` (event_time + knowledge_time, `revision_order` + tie-break, schema id + version, per-column units, license flag, `pit_fidelity`, + `valid_from` coverage, native frequency, `availability py|ts`) EXPRESSES the + legacy climate dedup (`report_type_priority` STRICT `>`, first-seen-wins) with + no special case inside `align()` (Path A — the cheapest falsifier, run first). +- **The LABEL / SPINE axis + raw spine constructors (D-34.09..12).** `mr.spine(df, + *, entity, decision_time, y)` (the BYO bridge; loud `ContractError` on a missing + mapped column, never guesses); `weather.days(stations, from_date, to_date, *, + tz_override=None)` (the public bare per-settlement-day spine; a list mints a + long-format panel byte-equal to the per-station loop); `weather.forecasts(entity, + ..., source="iem_mos", model=None, models=None)` (a registered bitemporal source + wrapping the shipped forecast join — the landing site for the four forecast + kwargs); and the `.label.`-registered spine factories `weather.label.cli()` / + `weather.label.daily_extremes()` / `markets.*.label.settlement()` (y columns + byte-identical to the recipes). A **spine is the primitive; a label is a spine + factory** over the same domain constructor, so train and trade share the + calendar-minting site (the structural fix for backtest/live settlement skew). +- **Per-domain `pairs()` quickstart (D-34.21).** `weather.pairs(entity, from_date, + to_date, *, label="cli")` — capped at ≤5 params, two positional dates, a + docstring showing its own `weather.days()` → `weather.label.cli()` → `align()` + desugaring, and a visible **graduation error** naming spine+align (never a bare + `TypeError`) when you pass an unsupported kwarg. `markets.kalshi.pairs` / + `markets.polymarket.pairs` are the renamed delegators. +- **Generalized `discover(text?, **filters)` (D-34.13/14).** Cross-domain catalog + search over the registration metadata (id/kind/domain/columns/units/availability/ + license/PIT fidelity) with deterministic field-weighted token scoring (no fuzzy + dep), per-domain entity **alias resolvers** (`"nyc"` → KNYC/KLGA, preserving the + cross-venue settlement note), a runnable language-tagged `{py, ts}` `usage` per + row, and an `availability` runtime filter. Keyword-only `city=` still works. +- **Cross-SDK `align()` conformance fixtures (D-34.14/25).** A shared four-axis + JSON suite (tie-break authority ⟂ arrival + first-seen tie, `<=` boundary + inclusivity, nanosecond precision, NaN preservation) under + `tests/fixtures/parity/conformance/`, driven as a HARD gate by Python against + the real join core and read by the identical fixtures from the TS suite. A + registry-generated `@mostlyrightmd/weather/catalog` lazy static-JSON subpath + (byte-deterministic under `export_schemas.py --check`), kept OUT of the + size-budgeted meta barrel (TS-BUNDLE-01). +- **Migration guide + error-copy conformance (D-34.24/26).** + `docs/migration/v117-align-spine-sources.md` (the 26-kwarg → new-home table, the + four forecast kwargs → `weather.forecasts()`, label's 5 jobs → 4 call shapes, + the three orphan kwargs homed, the namespace-inversion symbol map, TypeScript-on-1.17, + the econ-unchanged F-2 follow-up, and PIT-transform-out-of-scope). Every + user-facing error string from Phase 34 is audited against + `docs/error-copy-style-guide.md` (four required fields: source id, row/entity, + both timestamps where temporal, concrete fix) and built through the single + `_errmsg` construction path — including the unknown-entity `did you mean` + suggestion (`EntityNotFoundError`). + +### Changed + +- **Namespace inversion — root now reads as the product pitch (D-34.20).** Root = + machinery (`align`, `spine`, `discover`, `provenance`, `LeakageError` / + `ContractError` / `NoDataError`, `registry`, `contracts`, `__version__`) + + domains (`weather` / `markets` / `econ`). A symbol stays top-level only if it is + meaningful without naming a domain. +- **Collapsed error taxonomy (D-34.23).** `MostlyRightError` → `LeakageError` / + `ContractError` / `NoDataError`; `LiveStreamError` re-parented under `NoDataError` + (additive MRO) so `issubclass(NoCWOPDataError, NoDataError)` is True. The three + are importable one deep (`except mr.LeakageError:`). + +### Deprecated + +- **Relocated root symbols keep byte-stable importable shims (removal at 2.0).** + `from mostlyright import research/dataset/Station/CATALOG/StationCatalog` and + `mostlyright.live` now resolve via a once-per-session `DeprecationWarning` shim + naming the `mostlyright.weather.*` home (`mostlyright.research is + mostlyright.weather.research` — same object; parity is output bytes, not import + path). `markets.kalshi.dataset()` / `markets.polymarket.dataset()` → `pairs()` + with a deprecation shim. **Removal target: 2.0 (≥2 minors out), documented only.** +- **The D-22 label-default `FutureWarning` is GONE (D-34.22).** The 2.0 flip + (`"cli"` → `None`) is cancelled — there is no implicit label default in the new + surface, so an OMITTED label stays `"cli"` byte-stable with no warning, forever. + ## [1.16.0] — 2026-07-14 — Economic indicators vertical: first published `mostlyrightmd-econ` / `@mostlyrightmd/econ` dist First release of the **econ vertical** — CPI, PPI, nonfarm payrolls, unemployment, diff --git a/README.md b/README.md index b0d9c140..4065d045 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,7 @@ table, coverage map, auto-routing, cheap-CONUS steering, DSRF gating, the 28 TB - **Quickstart + concepts:** - **`dataset()` — the alignment engine:** [`docs/dataset.md`](docs/dataset.md) — the label axis, the contributor protocol, panels, and the honest with/without comparison. - **Source identity — the cross-vertical kwarg contract:** [`docs/source-identity.md`](docs/source-identity.md) — `source=` / `delivery=` / grain, the labels-only firewall, and the fetch-provenance ledger. +- **Migrating to 1.17.0 — the `align` / `spine` / `sources` ladder:** [`docs/migration/v117-align-spine-sources.md`](docs/migration/v117-align-spine-sources.md) — the where-did-each-`dataset()`-kwarg-go table (all 26), `label`'s 5 jobs → 4 call shapes, per-domain `pairs()`, the namespace-inversion symbol map, TypeScript-on-1.17, and the econ-unchanged note. Additive-plus-shims; the D-22 `FutureWarning` is gone. - **Migrating to 1.15.0:** [`docs/migration-1.15.md`](docs/migration-1.15.md) — the D-22 default-label `FutureWarning`, `include_*` → `features=`, and `contract=` → the markets layer. - **API reference:** (Python + TypeScript reference auto-generated per release) - **Migration from the legacy hosted-API client:** diff --git a/docs/error-copy-style-guide.md b/docs/error-copy-style-guide.md new file mode 100644 index 00000000..4ed74277 --- /dev/null +++ b/docs/error-copy-style-guide.md @@ -0,0 +1,80 @@ +# Error-copy style guide + +**Status:** wave-1 contract (Phase 34 01, R-17). Every user-facing error string +written in waves 1-4 conforms to this guide. Plan **34-08** performs only the +final audit against it — it does NOT author error copy. + +Errors are product surface: they are the moat's voice. A researcher who trips the +leakage guard should learn, from the message alone, *which* source leaked, *by +how much*, and *exactly what to do next* — without opening a traceback or the +source. + +## The four required fields + +Every user-facing error raised by `align()` / the source contract MUST carry: + +1. **Offending source id** — the `SourceContract.id` (e.g. `weather.climate.cli`) + at fault. In a multi-source `align()`, name the ONE source that failed; never + blame a clean co-aligned source (R-22). +2. **Row / entity** — the entity (station, ticker, …) and/or the row index the + error is about, so the user can locate it in their own frame. +3. **Both timestamps, where the error is temporal** — for a leak, print BOTH the + leaking `knowledge_time` and the `decision_time` cutoff it crossed. One + timestamp alone is not actionable. +4. **Concrete fix / next call** — the exact next action: a filter to apply, a + contract field to rename, or a verb to call (e.g. `mr.provenance(frame)`). + +Non-temporal errors (a malformed contract) satisfy field 3 vacuously. + +## Single construction path + +All `align()` / contract raises build their message text through the shared +helper at **`packages/core/src/mostlyright/_errmsg.py`**: + +- `leakage_message(...)` → the copy for `LeakageError` (fields 1-4). +- `contract_message(...)` → the copy for `ContractError` (fields 1, 2, 4). + +Do NOT hand-format error strings at the raise site. The exception CLASS +(`core/exceptions.py`) holds the STRUCTURED payload (`source`, +`violating_count`, `sample_violations`, `field`, …); the `_errmsg` builder holds +the WORDING. Keeping wording in one module is what lets 34-08 audit copy in one +place and what keeps Python/TS copy in lockstep. + +## Worked example: `LeakageError` + +A two-source `align()` where only `weather.forecasts.gfs` carries a row whose +`knowledge_time` is after the spine `decision_time`: + +``` +Leakage detected in source 'weather.forecasts.gfs': 3 row(s) carry a +knowledge_time after the spine decision_time cutoff 2025-01-02T00:00:00+00:00 +(e.g. knowledge_time=2025-01-05T00:00:00+00:00). Fix: filter source +'weather.forecasts.gfs' to knowledge_time <= your decision_time before calling +align(), or call mr.provenance(frame) to inspect the offending rows. +``` + +Checklist: offending source id ✅ (`weather.forecasts.gfs`); row/entity ✅ +(count + `sample_violations` carry `row_idx`); both timestamps ✅ +(`knowledge_time` + `decision_time` cutoff); concrete fix ✅ (filter to +`knowledge_time <= decision_time`, or `mr.provenance(frame)`). The clean +co-aligned source is never named. + +## Worked example: `ContractError` + +A source spec declares a `prefix` that does not end with `_`: + +``` +Contract violation in source 'weather.obs.iem' (field 'prefix'): prefix must be +a non-empty string ending with '_' (got 'obs'). Fix: set prefix to a column +namespace ending in '_' (e.g. 'obs_'). +``` + +Checklist: offending source id ✅ (`weather.obs.iem`); field/entity ✅ (`prefix`); +timestamps ✅ (vacuous — non-temporal); concrete fix ✅ (the exact value to set). + +## Ownership + +- **Waves 1-4** write every new error string against this guide, through + `_errmsg`. +- **34-08** runs the final audit: it verifies each user-facing raise routes + through `_errmsg` and carries all four fields. It does not re-author copy. diff --git a/docs/migration/v117-align-spine-sources.md b/docs/migration/v117-align-spine-sources.md new file mode 100644 index 00000000..f4757fc5 --- /dev/null +++ b/docs/migration/v117-align-spine-sources.md @@ -0,0 +1,311 @@ +# Migrating to 1.17 — the `align` / `spine` / `sources` ladder + +**Status:** 1.17.0 migration guide (Phase 34). **Nothing you run today breaks.** + +1.17 is **additive-plus-shims**: every 1.x entry point keeps returning the same +bytes at runtime. `research()`, `dataset()`, `markets.kalshi.dataset()`, +`from mostlyright import Station, CATALOG` — all still work, each now behind a +one-line deprecation shim that names its new home. **Removals happen at 2.0 +only**, with ≥2 minor releases of warning first. You do **not** need to change +any code to upgrade to 1.17. This guide is for when you *want* to move onto the +new surface — because you asked for it ("too much magic — I can't tell a feature +from the target at a call site; let me build the DataFrame myself"). + +The live-parity gate is byte-green 5/5 against `mostlyright==0.14.1` on the new +path — the redesign re-plumbs the composer onto `align()` **without** changing a +single output byte. + +--- + +## The one-paragraph pitch + +The old `dataset()` fused four concerns into one 26-keyword function: *where to +fetch*, *which features*, *what the target is*, and *how to shape the output*. +1.17 splits those into **two orthogonal axes and one composition operator**: + +- **SOURCES (features / X):** `mr..
(entity, from_date, to_date)` + → a plain, source-identified DataFrame. `weather.obs(...)`, + `weather.forecasts(...)`, `markets.kalshi.trades(...)`. +- **LABELS (targets / y):** `mr..label.(entity, from_date, to_date)` + → a **spine** frame `(entity, decision_time, label_available_time, y_*)`. The + spine defines which rows exist and the point-in-time cutoff. +- **`mr.align(spine, *sources)`** — as-of-joins each source onto the spine, + enforcing the leakage guard (`source.knowledge_time <= spine.decision_time`) + in exactly one place, for every domain. Its signature is **frozen at two + concepts, forever** — all policy lives on the source specs, never on `align`. + +Every intermediate product at every rung is a plain, inspectable `pd.DataFrame`. +That inspectability *is* the answer to "too much magic". + +--- + +## The ladder of control + +Three rungs, pick the one that matches how much you want the SDK to do: + +```python +import mostlyright as mr +from mostlyright import weather + +# Rung 3 — the per-domain quickstart one-liner (v0.14.1 heritage: client.pairs()). +pairs = weather.pairs("KNYC", "2025-01-01", "2025-01-31") # label="cli" default + +# Rung 2 — spine + align: the SDK does ONLY the dangerous step (the as-of join). +spine = weather.label.cli("KNYC", "2025-01-01", "2025-01-31") # the target y +obs = weather.obs("KNYC") # a source (X) +fcst = weather.forecasts("KNYC") # another source (X) +frame = mr.align(spine, obs, fcst) # leakage-guarded join + +# Rung 1 — raw source functions returning plain DataFrames (full manual, supported). +raw_obs = weather.obs("KNYC", "2025-01-01", "2025-01-31") # just the frame +# ...build your own spine + join however you like; mr.spine() lets you back in. +``` + +`pairs()` kwargs are **capped** (≤5 params). When you outgrow it, you **graduate +to spine + align** — you never add a kwarg. An unsupported `pairs()` kwarg raises +a targeted error pointing you at `align` and this guide, not a bare `TypeError`. +Each `pairs()` / factory docstring shows its own desugaring (the next rung down). + +--- + +## Where did each `dataset()` kwarg go? + +The old signature was **26 parameters** (`research.py:2809`; 3 positional + 23 +keyword). Every one has a home on the new surface — nothing is dropped. + +| # | Old `dataset()` kwarg | New home in 1.17 | +|---|-----------------------|------------------| +| 1 | `station` | **entity** arg on the spine + source calls (typed entity) | +| 2 | `from_date` | **window** — spine bounds; sources infer the window from the spine | +| 3 | `to_date` | **window** — inclusive end (quants think in settlement days) | +| 4 | `label` (5 jobs) | **SPLIT into 4 call shapes** — see the label section below | +| 5 | `labels_as_known` (`settled`/`publication_lag`) | the spine's `label_available_time` + the label factory's publication-delay arg | +| 6 | `city` | entity **alias resolver** on the spine factory + `discover(city=)` (keyword-only `city=` still works) | +| 7 | `stations` | **multi-entity spine (panel)** — `weather.days(stations=[...])`, long format | +| 8 | `contract` | `markets.kalshi.contracts(ticker)` spine factory | +| 9 | `contracts` | `markets.kalshi.contracts([...])` panel | +| 10 | `station_override` | **`markets.polymarket.contracts(..., station_override=)`** (orphan-kwarg home — the Polymarket denylist backstop) | +| 11 | `sources` | the explicit **`*sources` varargs** to `align()` | +| 12 | `source` | source-spec identity pin — `weather.obs(source="iem")` (the `SourceMismatchError` guarantee is preserved on the spec) | +| 13 | `include_trades` | **presence** of `markets.kalshi.trades()` in `*sources` (not a flag) | +| 14 | `include_forecast` | **presence** of `weather.forecasts(...)` in `*sources` | +| 15 | `forecast_model` | `weather.forecasts(model=...)` — a source opt on the spec | +| 16 | `forecast_models` | `weather.forecasts(models=[...])` — a source opt on the spec | +| 17 | `forecast_source` | `weather.forecasts(source=...)` (default `iem_mos`) — a source opt on the spec | +| 18 | `as_dataframe` | output convention on the `align()` result — an output knob, not an `align` kwarg | +| 19 | `tz_override` | **`weather.days(..., tz_override=)`** (orphan-kwarg home — settlement-calendar override) | +| 20 | `qc` | **`weather.obs(..., qc=)`** (orphan-kwarg home — a source-spec option) | +| 21 | `granularity` (`daily`/`observation`) | `weather.obs(granularity=...)` — a source-spec option, **never** on `align` | +| 22 | `include_satellite` | **presence** of `weather.satellite()` in `*sources` (a covariate, never a label) | +| 23 | `include_cwop` | **presence** of `weather.cwop.history()` in `*sources` (a covariate; **firewall — never a settlement source**) | +| 24 | `features` | **ELIMINATED** — replaced by the explicit `*sources` varargs (the whole point of the axis split) | +| 25 | `backend` (`pandas`/`polars`) | the `align()` output convention | +| 26 | `return_type` (`dataframe`/`list`) | the `align()` output convention (consolidates the 3 overlapping output knobs) | + +### The four forecast kwargs land on `weather.forecasts()` + +`weather.forecasts(entity, from_date=None, to_date=None, *, source="iem_mos", +model=None, models=None)` is a **registered bitemporal source** (created in +34-04) that wraps the shipped `dataset(include_forecast=True)` join — no new +fetcher, no changed join, byte-consistent with the old path. It is the single +landing site for all four forecast kwargs: + +| Old kwarg | New | +|-----------|-----| +| `include_forecast=True` | **presence** of `weather.forecasts(entity, from_date, to_date)` in `align()`'s `*sources` | +| `forecast_source` | `weather.forecasts(source=...)` | +| `forecast_model` | `weather.forecasts(model=...)` | +| `forecast_models` | `weather.forecasts(models=[...])` | + +So **all 26 rows have a real destination** — the obs+forecast NHIGH training set +is buildable as `align(weather.label.cli(e, d1, d2), weather.obs(e), weather.forecasts(e))`. + +### The three orphan kwargs (`station_override` / `tz_override` / `qc`) + +These were the "advanced escape hatch" class on the god-function — none is a +first-class axis. Each is now homed on the surface it semantically belongs to: + +- **`station_override`** → `markets.polymarket.contracts(..., station_override=)` + — venue knowledge (a station-denylist backstop) belongs on the venue. +- **`tz_override`** → `weather.days(..., tz_override=)` — the settlement calendar + is minted by the spine constructor, so a calendar override belongs there. +- **`qc`** → `weather.obs(..., qc=)` — QC is a property of the source frame, so + it is a source-spec option (matching the existing `obs(qc=)`). + +--- + +## `label`'s five jobs → four distinct call shapes + +The old `label=` did **five** things at once. 1.17 splits them so a call site +reads unambiguously: + +| Old `label=` job | New call shape | +|------------------|----------------| +| **Named recipe** (`"cli"`, `"daily_extremes"`) | `weather.label.cli(...)` / `weather.label.daily_extremes(...)` — a `.label.`-registered spine factory whose `y` columns are byte-identical to the recipe | +| **BYO whole DataFrame** (you built your own target) | `mr.spine(df, *, entity=, decision_time=, y=)` — the explicit bridge that lifts your frame into the guarded world (never guesses the mapping; `ContractError` on a missing mapped column) | +| **`None`** (features-only, no target) | align your sources onto a **bare** spine — `mr.align(weather.days(entity, d1, d2), weather.obs(entity))` — or just take the rung-1 raw source frame | +| **Venue gating** (market-settlement target) | `markets.kalshi.label.settlement(...)` / `markets.polymarket.label.settlement(...)` — market-settlement labels live under `markets.*.label.*`, meteorological labels under `weather.label.*` | + +That is the **fifth** job — the **D-22 `FutureWarning`** about the label default +silently flipping `"cli"` → `None` at 2.0 — **gone**. There is no implicit label +default in the new surface, so there is nothing to flip and no warning to port. +`pairs(..., label="cli")` is an explicit, documented, contractually-stable +quickstart default with **no** deprecation path — it is not the old bare +`dataset()` default and never emits a warning. + +> **Migrating pinned features is a retrain event, not a find-replace.** If you +> pinned a specific `features=[...]` / forecast-model set in a trained model, +> moving to explicit `*sources` changes *which columns you compose and in what +> order they appear* — the same information, but a different frame identity. Treat +> the migration of a production model as a **retrain**, re-fit against the new +> `align()` frame, and re-validate — do not mechanically swap the call and ship. + +--- + +## The `.label.` registration invariant + +`.label.*` is a **registration-time invariant**, not a naming convention: a +label registered outside `.label.` is rejected, and a source registered inside +it is rejected. This is the actual structural fix for "I can't tell a feature +from the target at the call site" — the namespace itself tells you. + +## The skew guard (why a label is a spine *factory*) + +A **spine is the primitive; a label is a spine factory** over the *same* domain +constructor. At **trade time there is no label** — serving uses a bare spine +(`weather.days()`, `markets.kalshi.contracts()`). At **train time** you use a +`.label.` factory built on that *same* constructor (`weather.label.cli()` calls +`weather.days()` then attaches the recipe's `y`). The settlement-calendar logic +is therefore minted in **one** place, so backtest and live cannot drift. + +--- + +## PIT-correct transforms are OUT of scope (read this) + +1.17 gives you leakage-free **joins**. It deliberately does **not** give you a +PIT-correct rolling-feature transform layer: + +- **Spine / label factories own the PIT entity universe.** Deciding *which + entities existed at `decision_time`* (delisted markets, closed stations) is a + point-in-time query that `align()` cannot see — that responsibility lives in + the spine/label factories, and is documented in their docstrings. +- **Aggregation-window lookahead is your responsibility.** A rolling feature + computed over latest-vintage cached data leaks *even under a correct join*. + 1.17's stance: rolling features are computed **inside sources** or **by you + post-`align()` at your own risk**. There is no PIT-correct transform DSL, on + purpose — refusing it is what prevents the next god-surface. +- **Cross-source derived features** (spreads, station pairs) are explicitly + **user-land, post-`align()`**. Refusing to build a feature-engineering DSL is + a design choice, not an omission. + +--- + +## Where did each root symbol go? (namespace inversion) + +Root now reads as the product pitch: **machinery + domains only**. A symbol stays +top-level only if it is meaningful *without* naming a domain. Weather-shaped +symbols moved under `mr.weather.*`, each behind a byte-stable importable shim +that emits a once-per-session `DeprecationWarning` naming its new home. + +| Old import | New canonical home | 1.x shim? | +|------------|--------------------|-----------| +| `from mostlyright import research` | `mostlyright.weather.research` | yes (same function object) | +| `from mostlyright import dataset` | `mostlyright.weather.dataset` | yes | +| `from mostlyright import Station` | `mostlyright.weather.stations.Station` | yes | +| `from mostlyright import CATALOG` | `mostlyright.weather.stations.CATALOG` | yes | +| `from mostlyright import StationCatalog` | `mostlyright.weather.stations.StationCatalog` | yes | +| `mostlyright.live` | `mostlyright.weather.live` | yes (same module) | +| `markets.kalshi.dataset(...)` | `markets.kalshi.pairs(...)` | yes | +| `markets.polymarket.dataset(...)` | `markets.polymarket.pairs(...)` | yes | + +**Stays at root (machinery):** `mr.align`, `mr.spine`, `mr.discover`, +`mr.provenance`, `mr.LeakageError` / `mr.NoDataError` / `mr.ContractError` +(one import deep, `requests.HTTPError` convention), the domains +(`mr.weather` / `mr.markets` / `mr.econ` / …), `mr.registry` and `mr.contracts` +(demoted from the researcher autocomplete), and `mr.__version__`. + +--- + +## Errors are product surface + +The collapsed taxonomy is `MostlyRightError` → `LeakageError` (guard tripped) / +`ContractError` (nonconforming frame) / `NoDataError` (nothing came back; +`NoLiveDataError` / `NoCWOPDataError` / `EntityNotFoundError` are subclasses). +All three are importable one deep: `except mr.LeakageError:`. + +Every user-facing error carries **the offending source id, the row/entity, both +timestamps (where temporal), and a concrete fix** — see +[docs/error-copy-style-guide.md](../error-copy-style-guide.md). Examples: + +``` +Leakage detected in source 'weather.forecasts.gfs': 3 row(s) carry a +knowledge_time after the spine decision_time cutoff 2025-01-02T00:00:00+00:00 +(e.g. knowledge_time=2025-01-05T00:00:00+00:00). Fix: filter source +'weather.forecasts.gfs' to knowledge_time <= your decision_time before calling +align(), or call mr.provenance(frame) to inspect the offending rows. +``` + +``` +unknown entity 'KNYX'; did you mean 'KNYC'? (nearest match in the static +registry — no fuzzy search) +``` + +--- + +## TypeScript on 1.17 + +The registry redesign is a **Python-first** landing. For the TypeScript SDK: + +- **Unchanged — keep using it as-is:** `research()` and `dataset()` on + `@mostlyrightmd/weather` / `@mostlyrightmd/markets` work exactly as before in + 1.17. No TS code changes are required to upgrade. +- **Python-only on 1.17:** the ladder verbs `align` / `spine` / `pairs` and the + `.label.` axis ship on the **Python** SDK only this release. The async TS + `align()` (browser fetch makes the materializing wrapper async) is + **deferred**. +- **Parity ticket:** the deferred TS async `align()` is tracked as + **PT-3401** ([`.planning/parity-tickets/PT-3401-ts-async-align.md`](../../.planning/parity-tickets/PT-3401-ts-async-align.md), + cross-linked from `.planning/CROSS-SDK-SYNC.md`). It also records the + **availability partial-parity policy** (weather sources are `py,ts` via the + `export_schemas.py` codegen; other domains stay `py` for 1.17 — + `discover({availability:"ts"})` filters to the ts-available rows), the + **py-only typed-error criterion** (R-21), and the + **markets/econ/meta catalog-subpath follow-up** (R-25). +- **The acceptance gate already exists:** the shared cross-SDK conformance + fixtures (`tests/fixtures/parity/conformance/` — tie-break authority, `<=` + boundary inclusivity, nanosecond precision, NaN preservation) are driven as a + HARD gate by Python today and read by the TS suite (semantic assertions + `it.skip`-deferred to PT-3401). When the TS aligner lands, those vectors are + its acceptance criteria. +- **Catalog stays out of the bundle:** the per-domain catalog manifest + (`@mostlyrightmd/weather/catalog`) is a lazy static-JSON subpath, never + re-exported by the size-budgeted `mostlyright` meta barrel (TS-BUNDLE-01). + +--- + +## Econ is UNCHANGED in 1.17 + +The `mostlyright.econ` vertical (shipped in 1.16.0) is **not touched** by the +registry redesign. `econ.series()` (and its `history()` alias), `econ.releases`, +`econ.snapshot()`, and `research_econ()` all keep working exactly as in 1.16 — +**no shims, no deprecation, no call-site changes**. The econ↔weather firewall +(`test_firewall.py`) stays green. + +Registering econ under the new two-axis registry — `econ.vintages()` (a +bitemporal ALFRED-style source), `econ.label.first_print()` (a `.label.` spine +factory over the settlement-grade first print), and `econ.pairs()` (the +quickstart) — is a **named follow-up ticket (F-2)**, not silent scope. It lands +in a later minor once the source/label contracts are stress-tested by a third +vertical (EDGAR), per the "two data points don't justify freezing plugin infra" +discipline. Until then, econ's 1.16 surface is the supported way to use it. + +--- + +## What ships at 2.0 (documented only — nothing removed in 1.17) + +The deprecation train is **documented, not executed** in 1.17. At **2.0** (≥2 +minors out, CHANGELOG-cited): the root `research`/`dataset`/`Station`/`CATALOG`/ +`StationCatalog`/`live` shims and the `markets.*.dataset()` shims are removed; +the new surface (`align` / `spine` / `pairs` / the `.label.` axis) is the only +way in. Every shim emits a once-per-session `DeprecationWarning` naming its new +home for the whole 1.x line, so you have the full runway to migrate. diff --git a/packages-ts/core/package.json b/packages-ts/core/package.json index 51853f01..74a06e68 100644 --- a/packages-ts/core/package.json +++ b/packages-ts/core/package.json @@ -1,6 +1,6 @@ { "name": "@mostlyrightmd/core", - "version": "1.16.0", + "version": "1.17.0", "description": "TypeScript SDK core for quants, ML pipelines, and AI agents: types, schemas, validators, temporal-safety primitives, and the research() join over weather data + prediction-market settlements. Local-first, no hosted backend.", "keywords": [ "weather", diff --git a/packages-ts/econ/package.json b/packages-ts/econ/package.json index 1ad611bc..65ffe305 100644 --- a/packages-ts/econ/package.json +++ b/packages-ts/econ/package.json @@ -1,6 +1,6 @@ { "name": "@mostlyrightmd/econ", - "version": "1.16.0", + "version": "1.17.0", "description": "Economic-indicator data for TypeScript / Node — CPI, PPI, nonfarm payrolls (NFP), U3 unemployment, initial jobless claims, GDP, and Fed decisions, joined to Kalshi + Polymarket econ markets for leakage-free settlement pairs. First-print / ALFRED vintage discipline. Direct public-API access, no hosted backend.", "keywords": [ "economic-indicators", diff --git a/packages-ts/markets/package.json b/packages-ts/markets/package.json index ec037942..a9531372 100644 --- a/packages-ts/markets/package.json +++ b/packages-ts/markets/package.json @@ -1,6 +1,6 @@ { "name": "@mostlyrightmd/markets", - "version": "1.16.0", + "version": "1.17.0", "description": "Prediction-market data for TypeScript / Node — Kalshi NHIGH/NLOW weather-contract resolvers, Polymarket discovery + settlement, and Kalshi + Polymarket trade history. For quants, backtesting, and ML training pipelines.", "keywords": [ "kalshi", diff --git a/packages-ts/meta/package.json b/packages-ts/meta/package.json index 13cde3b4..3efbfff5 100644 --- a/packages-ts/meta/package.json +++ b/packages-ts/meta/package.json @@ -1,6 +1,6 @@ { "name": "mostlyright", - "version": "1.16.0", + "version": "1.17.0", "description": "Public-data SDK for TypeScript — one import for quants, ML pipelines, and AI agents. Adapters ship weather (METAR, ASOS, GHCNh, NWS CLI) and prediction-market settlements (Kalshi NHIGH/NLOW, Polymarket) today; SEC filings, Federal Reserve series, court filings, FDA approvals, and equities are next. Local-first, no hosted backend.", "keywords": [ "weather", diff --git a/packages-ts/meta/src/__tests__/alignConformance.test.ts b/packages-ts/meta/src/__tests__/alignConformance.test.ts new file mode 100644 index 00000000..9dc4420a --- /dev/null +++ b/packages-ts/meta/src/__tests__/alignConformance.test.ts @@ -0,0 +1,98 @@ +// TypeScript side of the shared cross-SDK align() conformance suite (Phase 34 07). +// +// Reads the IDENTICAL JSON fixtures the Python HARD gate drives +// (tests/fixtures/parity/conformance/*.json) — one source of truth, two +// consumers. TS async align() is DEFERRED behind parity ticket PT-3401 +// (see .planning/CROSS-SDK-SYNC.md); until it lands, the SEMANTIC assertions +// (tie-break authority, boundary <=, ns precision, NaN preservation) are +// documented it.skip()s that reference the ticket. The fixture-structure +// assertions run green NOW so these vectors are verifiable the moment the TS +// aligner ships — no re-authoring, no per-language drift. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// packages-ts/meta/src/__tests__ → repo root is four levels up. +const CONFORMANCE_DIR = path.resolve(__dirname, "../../../../tests/fixtures/parity/conformance"); + +const KNOBS = ["tie_break", "boundary", "precision", "nan_semantics"] as const; +type Knob = (typeof KNOBS)[number]; + +interface ConformanceFixture { + readonly knob: string; + readonly title: string; + readonly description: string; + readonly spec: { + readonly id: string; + readonly prefix: string; + readonly entity_key: ReadonlyArray; + readonly event_time_col: string; + readonly knowledge_time_col: string; + readonly revision_order: ReadonlyArray>; + readonly revision_tie: string; + readonly pit_fidelity: string; + }; + readonly spine: ReadonlyArray>; + readonly source_rows: ReadonlyArray>; + readonly match_keys: ReadonlyArray; + readonly compare_columns: ReadonlyArray; + readonly expected: ReadonlyArray>; +} + +function loadFixture(knob: Knob): ConformanceFixture { + const raw = fs.readFileSync(path.join(CONFORMANCE_DIR, `${knob}.json`), "utf-8"); + return JSON.parse(raw) as ConformanceFixture; +} + +describe("align() conformance fixtures — shared cross-SDK truth", () => { + it("ships the four pinned-knob fixtures + README as the single source of truth", () => { + for (const knob of KNOBS) { + expect(fs.existsSync(path.join(CONFORMANCE_DIR, `${knob}.json`))).toBe(true); + } + expect(fs.existsSync(path.join(CONFORMANCE_DIR, "README.md"))).toBe(true); + }); + + it("pins the revision-collapse verdict (report_type_priority authority, first-seen tie)", () => { + const tie = loadFixture("tie_break"); + expect(tie.spec.revision_order).toEqual([["report_type_priority", "desc"]]); + expect(tie.spec.revision_tie).toBe("first_seen"); + }); + + it.each(KNOBS)("fixture %s is well-formed (spine, source_rows, expected)", (knob) => { + const f = loadFixture(knob); + expect(f.knob).toBe(knob); + expect(f.spine.length).toBeGreaterThan(0); + expect(f.source_rows.length).toBeGreaterThan(0); + expect(f.compare_columns.length).toBeGreaterThan(0); + // One expected row per spine row — rows are preserved, never dropped. + expect(f.expected.length).toBe(f.spine.length); + // Every expected row carries every match key + every compare column. + for (const row of f.expected) { + for (const key of f.match_keys) { + expect(row).toHaveProperty(key); + } + for (const col of f.compare_columns) { + expect(Object.prototype.hasOwnProperty.call(row, col)).toBe(true); + } + } + }); + + // ── Semantic execution: DEFERRED to PT-3401 (TS async align()) ────────────── + // These are the assertions that will drive the TS pure join core once it + // lands. They read the same fixtures the Python HARD gate proves today. + describe.each(KNOBS)("semantic execution for %s (deferred to PT-3401)", (knob) => { + it.skip(`TS align() reproduces the ${knob} expected output — PT-3401`, () => { + // When the TS aligner ships, drive it here against loadFixture(knob) and + // assert compare_columns match `expected` (null ⇒ NaN). See + // .planning/CROSS-SDK-SYNC.md PT-3401 for the acceptance gate. + const f = loadFixture(knob); + expect(f.expected.length).toBe(f.spine.length); + }); + }); +}); diff --git a/packages-ts/meta/src/discover.ts b/packages-ts/meta/src/discover.ts index a32fbc26..875efde5 100644 --- a/packages-ts/meta/src/discover.ts +++ b/packages-ts/meta/src/discover.ts @@ -1,10 +1,28 @@ // Phase 10 — discover({city}) ergonomic surface (TS port of // packages/core/src/mostlyright/discover.py). // -// Pre-research lookup. Shows quants which station settles which issuer's -// market for a given city so they can pick the right selector before -// invoking research(). Especially useful for cross-issuer cities like NYC -// where Kalshi settles against KNYC and Polymarket against KLGA. +// Phase 34 07 — GENERALIZED with a catalog `availability` filter. discover() +// now serves two shapes: +// +// 1. discover({ city }) → the Phase-10 station-neighborhood table +// (unchanged; cross-issuer settlement map). +// 2. discover({ availability, catalog }) → the cross-domain catalog surface: +// filters the per-domain manifest (generated from the Python registry by +// scripts/export_schemas.py, committed at @mostlyrightmd/weather/catalog) +// to the requested runtime and surfaces each row's language-tagged +// `usage.ts` — falling back to the row's note/docs pointer, NEVER a bare +// Python snippet (R-20). +// +// Browser-safe: the availability path is a PURE function over an injected +// catalog array (const-union typed, readonly result rows). The manifest is a +// LAZY static JSON asset — the barrel never imports it at the top level (it +// must stay out of the size-budgeted meta bundle, TS-BUNDLE-01); callers load it +// via `loadWeatherCatalog(url)` (fetch + JSON, no Node APIs) or inject their own. +// +// TS async align() itself is DEFERRED behind parity ticket PT-3401 +// (.planning/CROSS-SDK-SYNC.md) — browser-dashboard builders need sources + +// live snapshots first; the shared conformance fixtures guarantee it is +// verifiable when it lands. import { annotateSettlesFor, resolveCity } from "./compose.js"; @@ -28,25 +46,114 @@ export interface DiscoverResult { readonly source: "discover"; } +/** Per-source runtime availability (const union — never a TS `enum`). */ +export type Availability = "py" | "ts" | "py,ts"; + +/** Language-tagged usage snippet carried by a catalog entry (R-20). */ +export interface CatalogUsage { + readonly py?: string; + readonly ts?: string; +} + /** - * Return per-station discovery table for `city`. - * - * Each row shows one settlement station + the issuer:ticker markers that - * resolve against it. Stations in the per-city Polymarket denylist also - * appear with empty `settlesFor` so quants see the full neighborhood - * before deciding whether to use `stationOverride`. + * One entry in the per-domain catalog manifest (generated from the Python + * registry by `scripts/export_schemas.py`; committed at + * `@mostlyrightmd/weather/catalog`). Mirrors the Plan 05 + * `record_from_contract` record shape. + */ +export interface CatalogEntry { + readonly id: string; + readonly kind: string; + readonly domain: string; + readonly entity?: string; + readonly columns?: ReadonlyArray; + readonly units?: Readonly>; + readonly availability: Availability; + readonly license?: string; + readonly pit_fidelity?: string; + readonly note?: string; + readonly usage?: CatalogUsage; +} + +/** A row returned by the catalog (availability) form of `discover()`. */ +export interface CatalogRow { + readonly id: string; + readonly kind: string; + readonly domain: string; + readonly availability: Availability; + /** + * The language-tagged TS usage snippet for this row, OR the row's note/docs + * pointer when it has no `ts` usage — NEVER a bare Python snippet (R-20). + */ + readonly usageTs: string; + readonly note: string; +} + +/** Structured catalog query — the primary (scriptable) discover interface. */ +export interface DiscoverCatalogQuery { + /** Filter to sources reachable from this runtime. */ + readonly availability: Availability; + /** Optional domain narrowing (e.g. `"weather"`). */ + readonly domain?: string; + /** The catalog manifest entries (inject them, or `loadWeatherCatalog()`). */ + readonly catalog: ReadonlyArray; +} + +function isTsAvailable(a: Availability): boolean { + return a === "ts" || a === "py,ts"; +} + +function isPyAvailable(a: Availability): boolean { + return a === "py" || a === "py,ts"; +} + +function matchesAvailability(entry: CatalogEntry, wanted: Availability): boolean { + if (wanted === "ts") return isTsAvailable(entry.availability); + if (wanted === "py") return isPyAvailable(entry.availability); + // "py,ts" wanted ⇒ no runtime restriction (surface everything). + return true; +} + +/** + * Resolve the TS-facing usage for a catalog row (R-20). * - * @example - * const result = discover({ city: "NYC" }); - * // rows include {station: "KNYC", settlesFor: ["kalshi:NYC"]}, - * // {station: "KLGA", settlesFor: ["polymarket:nyc"]}, - * // {station: "KJFK", settlesFor: []}, // denylist - * // {station: "KEWR", settlesFor: []}. + * Prefers the language-tagged `usage.ts`; when absent, falls back to the row's + * `note` (its docs pointer). It NEVER falls back to `usage.py` — a TS user must + * never be shown a bare Python snippet. */ -export function discover(args: { readonly city: string }): DiscoverResult { - if (typeof args !== "object" || args === null) { - throw new TypeError(`discover(): args must be an object; got ${typeof args}`); +function resolveUsageTs(entry: CatalogEntry): string { + const ts = entry.usage?.ts; + if (typeof ts === "string" && ts.length > 0) return ts; + const note = entry.note; + if (typeof note === "string" && note.length > 0) return note; + return `see docs for ${entry.id} (no TS usage — availability: ${entry.availability})`; +} + +function discoverCatalog(query: DiscoverCatalogQuery): ReadonlyArray { + if (!Array.isArray(query.catalog)) { + throw new TypeError("discover(): { catalog } must be an array of CatalogEntry"); + } + const rows: CatalogRow[] = []; + for (const entry of query.catalog) { + if (query.domain !== undefined && entry.domain !== query.domain) continue; + if (!matchesAvailability(entry, query.availability)) continue; + rows.push( + Object.freeze({ + id: entry.id, + kind: entry.kind, + domain: entry.domain, + availability: entry.availability, + usageTs: resolveUsageTs(entry), + note: entry.note ?? "", + }), + ); } + // Deterministic order (id asc) — scriptable, mirrors the Python discover(). + rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return Object.freeze(rows); +} + +function discoverCity(args: { readonly city: string }): DiscoverResult { const stations = resolveCity(args.city); const rows: DiscoverRow[] = stations.map((station) => ({ city: args.city, @@ -59,3 +166,43 @@ export function discover(args: { readonly city: string }): DiscoverResult { source: "discover" as const, }); } + +/** + * Discover datasets — by city (station neighborhood) or by catalog availability. + * + * @example + * // Station neighborhood (Phase 10, unchanged): + * discover({ city: "NYC" }); + * // → rows include {station: "KNYC", settlesFor: ["kalshi:NYC"]}, … + * + * @example + * // Catalog availability filter (Phase 34 07): + * const catalog = await loadWeatherCatalog("/weather/catalog.json"); + * discover({ availability: "ts", catalog }); + * // → only ts-reachable rows, each carrying its `usageTs` snippet. + */ +export function discover(args: { readonly city: string }): DiscoverResult; +export function discover(args: DiscoverCatalogQuery): ReadonlyArray; +export function discover( + args: { readonly city: string } | DiscoverCatalogQuery, +): DiscoverResult | ReadonlyArray { + if (typeof args !== "object" || args === null) { + throw new TypeError(`discover(): args must be an object; got ${typeof args}`); + } + if ("catalog" in args) { + return discoverCatalog(args); + } + return discoverCity(args); +} + +/** + * Lazily fetch a per-domain catalog manifest (browser-safe: `fetch` + JSON, no + * Node APIs). Point `url` at the committed `@mostlyrightmd/weather/catalog` + * asset. Kept out of the barrel's top level so the manifest never enters the + * size-budgeted meta bundle (TS-BUNDLE-01). + */ +export async function loadWeatherCatalog(url: string): Promise> { + const res = await fetch(url); + const doc = (await res.json()) as { readonly entries?: ReadonlyArray }; + return Object.freeze(doc.entries ?? []); +} diff --git a/packages-ts/meta/src/index.ts b/packages-ts/meta/src/index.ts index 74f75c79..7c90318d 100644 --- a/packages-ts/meta/src/index.ts +++ b/packages-ts/meta/src/index.ts @@ -106,7 +106,17 @@ export { type StationOverrideWarning, } from "./compose.js"; -export { discover, type DiscoverResult, type DiscoverRow } from "./discover.js"; +export { + discover, + loadWeatherCatalog, + type Availability, + type CatalogEntry, + type CatalogRow, + type CatalogUsage, + type DiscoverCatalogQuery, + type DiscoverResult, + type DiscoverRow, +} from "./discover.js"; // Phase 11 — `mostlyright.live` ticker surface re-exported through the // meta package so all three import shapes resolve to the same surface: diff --git a/packages-ts/meta/tests/discover.availability.test.ts b/packages-ts/meta/tests/discover.availability.test.ts new file mode 100644 index 00000000..a8316e26 --- /dev/null +++ b/packages-ts/meta/tests/discover.availability.test.ts @@ -0,0 +1,118 @@ +// Phase 34 07 — discover() catalog availability filter + usage.ts surfacing. +// +// Two guarantees: +// R-08: discover({availability:"ts"}) reads the committed weather catalog +// manifest (generated from the Python registry by export_schemas.py) and +// returns > 0 weather rows on 1.17. +// R-20: a ts-available row WITH usage.ts surfaces it; a ts-available row +// WITHOUT usage.ts falls back to its note/docs pointer — NEVER a bare +// Python snippet. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { type CatalogEntry, discover } from "../src/discover.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// packages-ts/meta/tests → the committed weather catalog manifest. +const CATALOG_PATH = path.resolve(__dirname, "../../weather/catalog.json"); + +function loadCommittedCatalog(): ReadonlyArray { + const doc = JSON.parse(fs.readFileSync(CATALOG_PATH, "utf-8")) as { + entries?: ReadonlyArray; + }; + return doc.entries ?? []; +} + +describe("discover({ availability }) — catalog filter (R-08)", () => { + it("reads the committed weather manifest and returns > 0 ts-available weather rows", () => { + const catalog = loadCommittedCatalog(); + const rows = discover({ availability: "ts", domain: "weather", catalog }); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row.domain).toBe("weather"); + // ts-available means "ts" or "py,ts". + expect(row.availability === "ts" || row.availability === "py,ts").toBe(true); + // Every surfaced row carries a non-empty ts usage snippet (never bare py). + expect(typeof row.usageTs).toBe("string"); + expect(row.usageTs.length).toBeGreaterThan(0); + } + }); + + it("filters out py-only sources when availability:'ts' is requested", () => { + const catalog: ReadonlyArray = [ + { id: "weather.tsonly", kind: "source", domain: "weather", availability: "py,ts" }, + { id: "econ.pyonly", kind: "source", domain: "econ", availability: "py" }, + ]; + const ids = discover({ availability: "ts", catalog }).map((r) => r.id); + expect(ids).toContain("weather.tsonly"); + expect(ids).not.toContain("econ.pyonly"); + }); +}); + +describe("discover() surfaces usage.ts, never a bare Python snippet (R-20)", () => { + it("surfaces usage.ts when present", () => { + const catalog: ReadonlyArray = [ + { + id: "weather.hasts", + kind: "source", + domain: "weather", + availability: "py,ts", + note: "docs: /weather/hasts", + usage: { + py: 'weather.pairs("KNYC", "2024-01-01", "2024-01-31")', + ts: 'weather.pairs("KNYC", "2024-01-01", "2024-01-31")', + }, + }, + ]; + const rows = discover({ availability: "ts", catalog }); + const row = rows[0]; + expect(row).toBeDefined(); + if (!row) throw new Error("expected one row"); + expect(row.usageTs).toBe('weather.pairs("KNYC", "2024-01-01", "2024-01-31")'); + }); + + it("falls back to the note/docs pointer when a surfaced row has no ts usage — never usage.py", () => { + const pySnippet = 'import mostlyright as mr; mr.weather.pairs("KNYC")'; + const catalog: ReadonlyArray = [ + { + id: "weather.nots", + kind: "source", + domain: "weather", + availability: "py,ts", + note: "docs: /weather/nots (Python-only helper)", + usage: { py: pySnippet, ts: "" }, + }, + ]; + const rows = discover({ availability: "ts", catalog }); + const row = rows[0]; + expect(row).toBeDefined(); + if (!row) throw new Error("expected one row"); + expect(row.usageTs).toBe("docs: /weather/nots (Python-only helper)"); + expect(row.usageTs).not.toBe(pySnippet); + expect(row.usageTs).not.toContain("import mostlyright"); + }); + + it("returns rows deterministically ordered by id", () => { + const catalog: ReadonlyArray = [ + { id: "weather.z", kind: "source", domain: "weather", availability: "ts" }, + { id: "weather.a", kind: "source", domain: "weather", availability: "ts" }, + ]; + const ids = discover({ availability: "ts", catalog }).map((r) => r.id); + expect(ids).toEqual(["weather.a", "weather.z"]); + }); +}); + +describe("discover({ city }) back-compat is preserved", () => { + it("still returns the station-neighborhood table for a city", () => { + const result = discover({ city: "NYC" }); + expect(result.source).toBe("discover"); + expect(result.city).toBe("NYC"); + expect(result.rows.length).toBeGreaterThan(0); + }); +}); diff --git a/packages-ts/meta/vitest.config.ts b/packages-ts/meta/vitest.config.ts index 42b985a6..cfd047e3 100644 --- a/packages-ts/meta/vitest.config.ts +++ b/packages-ts/meta/vitest.config.ts @@ -78,7 +78,10 @@ export default defineConfig({ ], }, test: { - include: ["tests/**/*.test.ts"], + // Phase 34 07: the shared align() conformance suite lives next to the source + // it will drive (src/__tests__/) so the fixture-consuming test ships with the + // discover() generalization; keep the historical tests/ tree too. + include: ["tests/**/*.test.ts", "src/**/__tests__/*.test.ts"], exclude: ["**/*.live.test.ts", "**/node_modules/**", "**/dist/**"], setupFiles: ["./tests/setup-cache.ts"], coverage: { diff --git a/packages-ts/weather/README.md b/packages-ts/weather/README.md index 09368c16..fca32a69 100644 --- a/packages-ts/weather/README.md +++ b/packages-ts/weather/README.md @@ -32,3 +32,18 @@ for the architectural rationale, workaround paths, and v2.0+ roadmap. For the 7 major US stations with IEM MOS coverage (KNYC, KLAX, KORD, KMIA, KDEN, KSEA, KATL), `iemMosForecasts()` ships today as the recommended TS-side workaround. + +## The 1.17 registry redesign — what changed for TypeScript + +The Python SDK's 1.17 release introduces the `align` / `spine` / `sources` +ladder (`mr.align(spine, *sources)`, per-domain `pairs()`, the `.label.` +axis). **The TypeScript surface you use is unchanged in 1.17:** +`research()` / `dataset()` keep working exactly as before. The new ladder +verbs (`align` / `spine` / `pairs`) are **Python-only on 1.17** — the async +TS `align()` is deferred and tracked as parity ticket **PT-3401** (see +`.planning/CROSS-SDK-SYNC.md`). The shared cross-SDK conformance fixtures +(`tests/fixtures/parity/conformance/`) are the standing acceptance gate for +when the TS aligner lands. + +Full symbol-move and where-did-each-kwarg-go tables: +[docs/migration/v117-align-spine-sources.md](https://github.com/mostlyrightmd/mostlyright-sdk/blob/main/docs/migration/v117-align-spine-sources.md). diff --git a/packages-ts/weather/catalog.json b/packages-ts/weather/catalog.json new file mode 100644 index 00000000..5e9577a9 --- /dev/null +++ b/packages-ts/weather/catalog.json @@ -0,0 +1,57 @@ +{ + "domain": "weather", + "entries": [ + { + "availability": "py,ts", + "columns": [], + "domain": "weather", + "entity": "", + "id": "weather.forecasts", + "kind": "source", + "license": "unknown", + "note": "", + "pit_fidelity": "reconstructed", + "score": 0.0, + "units": {}, + "usage": { + "py": "weather.pairs(\"KNYC\", \"2024-01-01\", \"2024-01-31\")", + "ts": "await dataset(\"KNYC\", \"2024-01-01\", \"2024-01-31\")" + } + }, + { + "availability": "py", + "columns": [], + "domain": "weather", + "entity": "", + "id": "weather.label.cli", + "kind": "label", + "license": "unknown", + "note": "", + "pit_fidelity": "reconstructed", + "score": 0.0, + "units": {}, + "usage": { + "py": "weather.pairs(\"KNYC\", \"2024-01-01\", \"2024-01-31\")", + "ts": "" + } + }, + { + "availability": "py", + "columns": [], + "domain": "weather", + "entity": "", + "id": "weather.label.daily_extremes", + "kind": "label", + "license": "unknown", + "note": "", + "pit_fidelity": "reconstructed", + "score": 0.0, + "units": {}, + "usage": { + "py": "weather.pairs(\"KNYC\", \"2024-01-01\", \"2024-01-31\")", + "ts": "" + } + } + ], + "version": 1 +} diff --git a/packages-ts/weather/package.json b/packages-ts/weather/package.json index 89be38c6..fd175633 100644 --- a/packages-ts/weather/package.json +++ b/packages-ts/weather/package.json @@ -1,6 +1,6 @@ { "name": "@mostlyrightmd/weather", - "version": "1.16.0", + "version": "1.17.0", "description": "Weather data for TypeScript / Node — live METAR (AWC), ASOS archive (IEM), historical observations (GHCNh), and NWS climate text products (CLI). For quants, ML training pipelines, and weather-bot agents. Direct public-API access, no hosted backend.", "keywords": [ "weather", @@ -66,9 +66,14 @@ "types": "./dist/hosted/index.d.ts", "import": "./dist/hosted/index.mjs", "require": "./dist/hosted/index.cjs" + }, + "./catalog": { + "import": "./catalog.json", + "require": "./catalog.json", + "default": "./catalog.json" } }, - "files": ["dist"], + "files": ["dist", "catalog.json"], "scripts": { "prebuild": "pnpm --filter @mostlyrightmd/codegen run codegen", "build": "tsup", diff --git a/packages/core/pyproject.toml b/packages/core/pyproject.toml index ec20a942..3b9cef7d 100644 --- a/packages/core/pyproject.toml +++ b/packages/core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mostlyrightmd" -version = "1.16.0" +version = "1.17.0" description = "Python SDK for quants, ML engineers, and AI agents — one interface to public data. Adapters ship weather + prediction-market settlements (Kalshi NHIGH/NLOW, Polymarket) today; SEC filings, Federal Reserve series, court filings, FDA approvals, and equities are next. Schema-versioned, leakage-free, local-first. Imports as `mostlyright`." readme = "README.md" license = "MIT" diff --git a/packages/core/src/mostlyright/__init__.py b/packages/core/src/mostlyright/__init__.py index 57ab942f..e84cb41b 100644 --- a/packages/core/src/mostlyright/__init__.py +++ b/packages/core/src/mostlyright/__init__.py @@ -1,26 +1,40 @@ """mostlyright — local-first SDK for prediction-market weather settlement research. -Sprint 0 v0.1.0 ships: -- ``mostlyright.dataset(station, from_date, to_date, ...)`` — the v0.14.1 ``pairs()`` join, - lifted from monorepo-v0.14.1, calling AWC + IEM + GHCNh + NWS CLI directly. - ``mostlyright.research`` remains a fully working alias of ``dataset`` (Phase 30 D-02). -- ``mostlyright.snapshot`` — settlement-window math (LST, market_close_utc). - -Adjacent surfaces: -- ``mostlyright.weather`` — observations + climate + forecasts (sibling package ``mostlyrightmd-weather``). -- ``mostlyright.markets`` — Kalshi + Polymarket metadata (sibling package ``mostlyrightmd-markets``, - ships v0.1.0 in Sprint 0.5). - -Namespace note: ``mostlyright`` is a split-distribution namespace package. Core owns this -``__init__.py``; sibling distributions ``mostlyrightmd-weather`` and ``mostlyrightmd-markets`` ship -subdirectories (``mostlyright/weather/``, ``mostlyright/markets/``) WITHOUT their own -namespace-root ``__init__.py``. The pkgutil declaration below extends ``__path__`` so Python's -import machinery finds those subpackages from whichever site-packages location installed them. +Phase 34 06 namespace inversion (34-CONTEXT "the cut rule"): the root reads as +the product pitch — **machinery + domains only**. A symbol stays at root only if +it is meaningful without naming a domain: + +- ``mr.align(spine, *sources)`` — the verb of the SDK (as-of join + leakage guard). +- ``mr.spine(df, *, entity, decision_time, y)`` — the BYO bridge into the guarded world. +- ``mr.discover(text?, **filters)`` — cross-domain catalog search. +- ``mr.provenance(frame)`` — the trust/audit "where did this column come from?" verb. +- ``mr.LeakageError`` / ``mr.ContractError`` / ``mr.NoDataError`` — the collapsed error + taxonomy, one import deep (``except mr.LeakageError:``). +- ``mr.weather`` / ``mr.markets`` — the domains; the root IS the map. +- ``mr.registry`` / ``mr.contracts`` — demoted (author-facing / met via error messages). +- ``mr.__version__`` — bug reports. + +Legacy weather-shaped symbols moved OUT of root under the cut rule: ``Station`` / +``CATALOG`` / ``StationCatalog`` → ``mostlyright.weather.stations``; ``live`` → +``mostlyright.weather.live``; ``research()`` / ``dataset()`` → ``mostlyright.weather.*`` +(and the per-domain quickstart ``weather.pairs()``). They are DROPPED from +``__all__`` + docs but kept IMPORTABLE as ``__getattr__`` shims through 1.x +(removal only at 2.0), each emitting a once-per-session ``DeprecationWarning`` +that names the new home. The shims keep the EXISTING code path — ``research()`` +byte-parity survives because parity is output bytes, not import path. + +Namespace note: ``mostlyright`` is a split-distribution namespace package. Core +owns this ``__init__.py``; sibling distributions ``mostlyrightmd-weather`` and +``mostlyrightmd-markets`` ship subdirectories (``mostlyright/weather/``, +``mostlyright/markets/``) WITHOUT their own namespace-root ``__init__.py``. The +pkgutil declaration below extends ``__path__`` so Python's import machinery finds +those subpackages from whichever site-packages location installed them. """ # Split-distribution namespace: extend __path__ to discover sibling packages' contributions. __path__ = __import__("pkgutil").extend_path(__path__, __name__) +import warnings from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _dist_version @@ -29,46 +43,154 @@ except PackageNotFoundError: # editable/source tree without installed dist metadata __version__ = "0.0.0+unknown" +# --- Root machinery --------------------------------------------------------- +# ``align`` / ``spine`` are eager: they are the headline verbs and MUST resolve as +# FUNCTIONS at root (``mr.align`` / ``mr.spine``). Eager-binding them here shadows +# the same-named submodules (``mostlyright.align`` / ``mostlyright.spine``) stably — +# the standard "re-export a function that collides with a submodule name" pattern. +# A lazy ``__getattr__`` cannot do this: the FIRST ``from mostlyright.align import ...`` +# anywhere binds the submodule as a package attribute, which then shadows a lazy +# function shim permanently. align.py / spine.py import ``core.exceptions`` LAZILY +# (Phase 34 06) precisely so this eager bind does NOT pull ``core.schemas`` (the +# satellite covariate schema) into a bare ``import mostlyright`` — keeping the +# flags-off import-cost guard green. ``discover`` is eager (stdlib-lazy internally). +# The three errors resolve lazily via ``__getattr__`` (no submodule collision). +from mostlyright.align import align from mostlyright.discover import discover -from mostlyright.research import dataset, research -from mostlyright.stations import CATALOG, Station, StationCatalog +from mostlyright.spine import spine +# ``mr.`` = machinery + domains only. Legacy symbols (research, dataset, +# Station, CATALOG, StationCatalog, live) are intentionally ABSENT — reachable +# via ``__getattr__`` shims (below) with a DeprecationWarning, removed at 2.0. __all__ = [ - "CATALOG", - "Station", - "StationCatalog", + "ContractError", + "LeakageError", + "NoDataError", "__version__", - "dataset", + "align", + "contracts", "discover", - "live", + "markets", "provenance", - "research", + "registry", + "spine", + "weather", ] -# Lazy `mostlyright.live` access (Phase 11). Both `discover` and `research` -# above already eagerly import `mostlyright.core` (which pulls pandas via -# `core.validator`), so the eager-pandas path is pre-existing and NOT a -# Phase 11 regression. Even so, we expose `live` through a module-level -# `__getattr__` hook so `import mostlyright` doesn't pull in -# `mostlyright.weather` (via the live module's deferred fetcher imports -# that fire on first use, not first attribute access). First access via -# `mostlyright.live.stream(...)` resolves and caches the submodule. -def __getattr__(name: str): +# --------------------------------------------------------------------------- +# Legacy-root-symbol shims (Phase 34 06 namespace inversion). +# --------------------------------------------------------------------------- + +#: Names whose once-per-session shim DeprecationWarning has already fired. +_LEGACY_SHIM_WARNED: set[str] = set() + +#: The deprecated legacy root symbols → their canonical new home + copy. Kept +#: IMPORTABLE through 1.x (removed at 2.0). The message names the new home so a +#: caller's migration is a copy-paste. +_LEGACY_SHIM_HOMES: dict[str, str] = { + "research": "mostlyright.weather.research", + "dataset": "mostlyright.weather.dataset", + "Station": "mostlyright.weather.stations", + "CATALOG": "mostlyright.weather.stations", + "StationCatalog": "mostlyright.weather.stations", + "live": "mostlyright.weather.live", +} + + +def _warn_legacy_shim(name: str) -> None: + """Fire the once-per-session DeprecationWarning for a legacy root symbol.""" + if name in _LEGACY_SHIM_WARNED: + return + home = _LEGACY_SHIM_HOMES[name] + if name == "research": + detail = f"use {home} (or the per-domain quickstart mostlyright.weather.pairs())" + elif name == "dataset": + detail = ( + f"use {home} (or the per-domain quickstart mostlyright.weather.pairs()); " + "the name 'dataset' is now reserved for the catalog noun (what discover() returns)" + ) + else: + suffix = "" if name == "live" else f".{name}" + detail = f"use {home}{suffix}" + warnings.warn( + f"mostlyright.{name} is deprecated ({detail}). " + "Removal target: 2.0 (>=2 minor releases out).", + DeprecationWarning, + stacklevel=3, + ) + _LEGACY_SHIM_WARNED.add(name) + + +def _resolve_legacy_shim(name: str): + """Return the object at a legacy symbol's canonical home (byte-stable).""" + import importlib + + if name in ("research", "dataset"): + # Byte-stable: hand back the SAME core function object (parity is output + # bytes, not import path). Importing the submodule may auto-bind + # ``mostlyright.research`` as a package attribute; the caller scrubs it so + # ``__getattr__`` stays authoritative for the ``research`` shim. + research_mod = importlib.import_module("mostlyright.research") + return getattr(research_mod, name) + if name in ("Station", "CATALOG", "StationCatalog"): + # Canonical public home is ``mostlyright.weather.stations`` (a re-export of + # this core implementation). Resolve from core so the shim never hard-requires + # the weather distribution; the SAME objects are exposed either way. + stations_mod = importlib.import_module("mostlyright.stations") + return getattr(stations_mod, name) if name == "live": - import mostlyright.live as _live + return importlib.import_module("mostlyright.live") + raise AttributeError(name) # pragma: no cover - guarded by caller + + +def __getattr__(name: str): + """Resolve root machinery-domains lazily + legacy symbols via deprecated shims. + + Machinery/domains (``weather``, ``markets``, ``registry``, ``contracts``, + ``provenance``) resolve WITHOUT a warning. Legacy symbols (``research``, + ``dataset``, ``Station``, ``CATALOG``, ``StationCatalog``, ``live``) resolve + via a once-per-session ``DeprecationWarning`` shim naming the new home. + """ + # --- Machinery / domains (no warning) --- + if name in ("LeakageError", "ContractError", "NoDataError"): + # One import deep at root (``except mostlyright.LeakageError:``). Lazy so a + # bare ``import mostlyright`` never triggers ``mostlyright.core.__init__`` + # (→ core.schemas → satellite covariate schema; the flags-off guard). + import mostlyright.core.exceptions as _exc - # Cache on the module so subsequent accesses skip __getattr__. - globals()["live"] = _live - return _live + cls = getattr(_exc, name) + globals()[name] = cls + return cls if name == "provenance": - # Phase 32 D-25: the fetch-provenance ledger read API - # (``mostlyright.provenance.history(station, window)``). Lazy so - # ``import mostlyright`` stays free of the ledger's filelock import - # until first use. The underscore module is the impl; the public - # ``provenance`` name is the documented surface. - import mostlyright._provenance as _provenance - - globals()["provenance"] = _provenance - return _provenance + # ``mostlyright.provenance`` is the CALLABLE lineage verb ``provenance(frame)``; + # the Phase-32 fetch-provenance ledger reads stay reachable as attributes on + # it. Lazy so ``import mostlyright`` stays free of the ledger's filelock import. + from mostlyright._provenance import provenance as _provenance_verb + + globals()["provenance"] = _provenance_verb + return _provenance_verb + if name in ("weather", "markets", "registry", "contracts"): + import importlib + + module = importlib.import_module(f"mostlyright.{name}") + globals()[name] = module + return module + + # --- Legacy shims (once-per-session DeprecationWarning) --- + if name in _LEGACY_SHIM_HOMES: + _warn_legacy_shim(name) + obj = _resolve_legacy_shim(name) + # Resolving a shim imports ``mostlyright.research`` / ``mostlyright.live``, + # whose import machinery auto-binds them as package attributes here — which + # would shadow the ``research``/``live`` shims on the next access (skipping + # the deprecation warning + returning a MODULE for ``research``). Scrub them + # so ``__getattr__`` stays authoritative (once-per-session is governed by + # ``_LEGACY_SHIM_WARNED``, not attribute caching). Internal code reaches the + # submodules via ``sys.modules`` / ``from mostlyright.research import ...``, + # never these package attributes. + globals().pop("research", None) + globals().pop("live", None) + return obj + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/packages/core/src/mostlyright/_contributors.py b/packages/core/src/mostlyright/_contributors.py index d7c23355..c85e84bc 100644 --- a/packages/core/src/mostlyright/_contributors.py +++ b/packages/core/src/mostlyright/_contributors.py @@ -79,6 +79,7 @@ __all__ = [ "ContributorSpec", "InjectionPoint", + "RegistryKind", "compose_post_join_contributors", "contributor", "get_contributor", @@ -103,6 +104,15 @@ #: (critic #3). The composer broadcasts daily contributions at observation grain. NativeGrain = Literal["daily", "observation"] +#: The registry ``kind`` axis (Phase 34 34-02, D-34.05/07). A registered entry is +#: either a ``"source"`` (a feature / X frame) or a ``"label"`` (a settlement +#: target / y frame). This makes feature-vs-target a machine-checkable property of +#: a catalog entry rather than a call-site convention: ``mr.registry.source()`` +#: writes ``kind="source"`` entries, ``mr.registry.label()`` writes ``kind="label"`` +#: entries, and the ``.label.`` path invariant is enforced at registration time. +#: The four Phase-32 built-in contributors default to ``"source"`` (byte-stable). +RegistryKind = Literal["source", "label"] + #: The contribute() callable signature (neutral — critic #1). ``entity`` is the #: vertical's key value (station in v1), ``window`` is ``(from_iso, to_iso)``, #: ``grain`` is the requested grain ("daily" in v1). Returns a mapping of @@ -166,6 +176,11 @@ class ContributorSpec: empty the aligner falls back to the first-seen-across-days universe. builtin: ``True`` for the four migrated built-ins (exempt from the ExperimentalFeatureWarning + third-party-prefix guard). + kind: ``"source"`` (feature / X frame) | ``"label"`` (settlement target / + y frame). Additive (Phase 34 34-02, D-34.05/07); every existing + registration defaults to ``"source"`` so the four Phase-32 built-ins + stay byte-stable. ``mr.registry.source()`` / ``mr.registry.label()`` + write this axis and enforce the ``.label.`` path invariant. """ name: str @@ -177,6 +192,7 @@ class ContributorSpec: native_grain: NativeGrain = "daily" columns: tuple[str, ...] = () builtin: bool = False + kind: RegistryKind = "source" # --------------------------------------------------------------------------- diff --git a/packages/core/src/mostlyright/_errmsg.py b/packages/core/src/mostlyright/_errmsg.py new file mode 100644 index 00000000..457df5d2 --- /dev/null +++ b/packages/core/src/mostlyright/_errmsg.py @@ -0,0 +1,77 @@ +"""Shared user-facing error-message builders (Phase 34 01, R-17). + +Every user-facing error that ``align()`` / the source contract raises is +constructed through ONE of these builders so the copy is uniform and conforms to +``docs/error-copy-style-guide.md``. The guide's four required fields: + +1. the offending **source id**, +2. the **row / entity** at fault, +3. **both timestamps** where the error is temporal (the leaking + ``knowledge_time`` and the ``decision_time`` cutoff it crossed), +4. the concrete **fix / next call** the user should make. + +Waves 1-4 write their error strings against this module; 34-08 runs only the +final audit against the guide (it does NOT re-author the copy). + +These builders are deliberately tiny and pure (str in → str out): they hold the +WORDING contract, while the exception classes in ``core.exceptions`` hold the +STRUCTURED payload (``source``, ``violating_count``, ``sample_violations`` …). +""" + +from __future__ import annotations + + +def leakage_message( + *, + source_id: str, + decision_time: str, + knowledge_time: str, + count: int = 1, + entity: str | None = None, +) -> str: + """Build the copy for a per-source ``LeakageError`` (R-22 / R-17). + + Carries the offending source id, the ``decision_time`` cutoff, one leaking + ``knowledge_time``, and a concrete fix — so a two-source align names WHICH + source leaked and never blames a clean co-aligned source. + """ + where = f" (entity {entity})" if entity else "" + return ( + f"Leakage detected in source '{source_id}'{where}: {count} row(s) carry a " + f"knowledge_time after the spine decision_time cutoff {decision_time} " + f"(e.g. knowledge_time={knowledge_time}). " + f"Fix: filter source '{source_id}' to knowledge_time <= your decision_time " + f"before calling align(), or call mr.provenance(frame) to inspect the " + f"offending rows." + ) + + +def contract_message( + *, + source_id: str, + field: str, + problem: str, + fix: str, +) -> str: + """Build the copy for a ``ContractError`` (nonconforming source spec/frame). + + Names the offending source id, the field at fault, the problem, and the + concrete field to change. + """ + return f"Contract violation in source '{source_id}' (field '{field}'): {problem}. Fix: {fix}." + + +def entity_not_found_message(*, alias: str, suggestion: str) -> str: + """Build the copy for an ``EntityNotFoundError`` (unknown-entity path, R-07/R-17). + + Names the offending alias (field 2, row/entity) and the concrete fix (field 4): + the deterministic nearest-match catalog suggestion — e.g. + ``no station 'KNYX' — did you mean KNYC?``. Non-temporal, so both-timestamps + (field 3) is satisfied vacuously. Routing this wording through ``_errmsg`` keeps + the unknown-entity copy on the single construction path the style guide mandates, + in lockstep with :func:`leakage_message` / :func:`contract_message`. + """ + return ( + f"unknown entity {alias!r}; did you mean {suggestion!r}? " + f"(nearest match in the static registry — no fuzzy search)" + ) diff --git a/packages/core/src/mostlyright/_internal/_capabilities.py b/packages/core/src/mostlyright/_internal/_capabilities.py index 6ae23544..b6d64f7b 100644 --- a/packages/core/src/mostlyright/_internal/_capabilities.py +++ b/packages/core/src/mostlyright/_internal/_capabilities.py @@ -16,10 +16,10 @@ # --------------------------------------------------------------------------- # Schema file mapping -# "pairs" is intentionally omitted — specs/settlement-join.json describes the -# observation-settlement join schema, not the pairs() output columns. -# A specs/pairs.json spec is deferred to Sprint 4. Until then, use -# feature_catalog(source_filter="pairs") to inspect pairs columns. +# Phase 34 06 (F-1): "pairs" is now a first-class entity — specs/pairs.json +# describes the settlement-join OUTPUT columns pairs()/dataset()/research() return +# (the v0.14.1 training-pair contract), distinct from settlement-join.json (the +# INTERNAL observation<->settlement join). ``load_schema("pairs")`` resolves. # --------------------------------------------------------------------------- _SCHEMA_FILES: dict[str, str] = { @@ -38,6 +38,8 @@ "brackets": "brackets.json", # Phase 3.1 — daily_extreme.v1 resolution schema for daily_extremes() rollup. "daily_extreme": "daily_extreme.json", + # Phase 34 06 (F-1) — the pairs() OUTPUT schema (v0.14.1 training-pair columns). + "pairs": "pairs.json", } # 5 additional specs ship in ``mostlyright/_internal/specs/`` but are intentionally @@ -87,11 +89,7 @@ def load_schema(entity: str) -> dict[str, Any]: """ if entity not in _SCHEMA_FILES: valid = sorted(_SCHEMA_FILES) - raise ValueError( - f"Unknown entity {entity!r}. " - f"Supported: {valid}. " - f"Note: 'pairs' is not supported — see feature_catalog(source_filter='pairs')." - ) + raise ValueError(f"Unknown entity {entity!r}. Supported: {valid}.") # Codex review W2-C P2 fix: dict(...) only copies the top-level mapping; # nested objects (properties, $ref blocks) remain shared with _SCHEMA_CACHE. # Caller-side mutations would corrupt every subsequent load_schema() call diff --git a/packages/core/src/mostlyright/_internal/_pairs.py b/packages/core/src/mostlyright/_internal/_pairs.py index c47176b6..418ddbed 100644 --- a/packages/core/src/mostlyright/_internal/_pairs.py +++ b/packages/core/src/mostlyright/_internal/_pairs.py @@ -66,6 +66,26 @@ _MARKET_CLOSE_MINUTE_LST = 30 # :30 +def station_key(station: str) -> str: + """The spine join-key form of ``station``: the normalized registry code. + + ``"KNYC"`` → ``"NYC"`` — the v0.14.1 heritage key that ``obs()`` daily + frames and ``research()``/``pairs()`` outputs carry (the parity fixtures + pin it), so every ``align()`` equality join matches without per-source + restamps. Shared by EVERY spine constructor (``weather.days()`` and the + ``markets.*`` ``_spine_days``) — a spine that stamps the station as typed + silently NaN's every joined feature column (review-iter-2/3). An + out-of-registry station (the ``tz_override`` escape hatch) keeps its id + as typed. + """ + from mostlyright.research import _resolve_station + + try: + return _resolve_station(station, allow_intl=True).code + except ValueError: + return station + + def market_close_utc(date_str: str, station: str, *, tz_override: str | None = None) -> datetime: """Return the UTC time of the Kalshi market close for a settlement date. diff --git a/packages/core/src/mostlyright/_internal/specs/pairs.json b/packages/core/src/mostlyright/_internal/specs/pairs.json new file mode 100644 index 00000000..5009095a --- /dev/null +++ b/packages/core/src/mostlyright/_internal/specs/pairs.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mostlyright.xyz/schemas/pairs.json", + "title": "Training Pairs Output", + "description": "The settlement-join OUTPUT columns returned by pairs()/dataset()/research() — the v0.14.1 leakage-free training-pair contract (one row per station settlement day). Distinct from settlement-join.json (the INTERNAL observation<->settlement join contract): this describes the columns a researcher actually receives. Base columns are always present; fcst_* columns appear only when a forecast source is aligned (include_forecast=True / weather.forecasts()).", + "type": "object", + "required": ["date", "station"], + "properties": { + "date": { + "type": "string", + "format": "date", + "description": "Local station settlement date (YYYY-MM-DD). Timezone: UTC observation -> station Local Standard Time -> date (NWS uses LST year-round; during DST the observation window is 1:00 AM to 12:59 AM next day)." + }, + "station": { + "type": "string", + "pattern": "^[A-Z]{3,4}$", + "description": "NWS station code (3-letter) or ICAO (4-letter) — the entity key. First column on a multi-station (panel) frame." + }, + "cli_high_f": { + "description": "Daily high from the NWS CLI settlement product (the Kalshi NHIGH ground-truth label), degrees Fahrenheit.", + "oneOf": [{ "type": "number" }, { "type": "null" }] + }, + "cli_low_f": { + "description": "Daily low from the NWS CLI settlement product (the Kalshi NLOW ground-truth label), degrees Fahrenheit.", + "oneOf": [{ "type": "number" }, { "type": "null" }] + }, + "obs_high_f": { + "description": "Highest observed temp_f from the merged METAR observations for this station+date (feature).", + "oneOf": [{ "type": "number" }, { "type": "null" }] + }, + "obs_low_f": { + "description": "Lowest observed temp_f from the merged METAR observations for this station+date (feature).", + "oneOf": [{ "type": "number" }, { "type": "null" }] + }, + "obs_high_at": { + "description": "UTC timestamp (ISO 8601) of the obs_high_f observation.", + "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] + }, + "obs_low_at": { + "description": "UTC timestamp (ISO 8601) of the obs_low_f observation.", + "oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] + }, + "fcst_high_f": { + "description": "Forecast daily high (degrees Fahrenheit) — present only when a forecast source is aligned (include_forecast=True / weather.forecasts()).", + "oneOf": [{ "type": "number" }, { "type": "null" }] + }, + "fcst_low_f": { + "description": "Forecast daily low (degrees Fahrenheit) — present only when a forecast source is aligned.", + "oneOf": [{ "type": "number" }, { "type": "null" }] + } + }, + "$comment": "COLUMN CONTRACT (v0.14.1 client.pairs parity): base = date, station, cli_high_f, cli_low_f, obs_high_f, obs_low_f, obs_high_at, obs_low_at. The cli_* columns are the settlement LABEL (Kalshi NHIGH/NLOW settle on NWS CLI); obs_* are the merged-observation FEATURES (AWC > IEM > GHCNh source priority). fcst_* are additive and appear only under an aligned forecast source. This is the OUTPUT schema of the pairs() one-liner (rung 3) and of dataset()/research(); it is NOT the internal observation<->settlement join in settlement-join.json." +} diff --git a/packages/core/src/mostlyright/_provenance.py b/packages/core/src/mostlyright/_provenance.py index ae4dba17..3e433206 100644 --- a/packages/core/src/mostlyright/_provenance.py +++ b/packages/core/src/mostlyright/_provenance.py @@ -281,4 +281,44 @@ def history(station: str, window: str | None = None) -> list[dict[str, Any]]: return out -__all__ = ["history", "record", "row_window_hash"] +def provenance(frame: Any) -> Any: + """Callable lineage verb over a composed frame (Phase 34 03, R-12). + + Reads the per-column source identity ``align`` stamped on + ``frame.attrs["provenance"]`` AND the per-column ``valid_from``/coverage edge + on ``frame.attrs["coverage"]`` into a per-column lineage DataFrame with + columns ``column`` / ``source`` / ``valid_from``. The coverage edge makes an + all-NaN column prefix explainable ("NaN before 2024-03-01: source cwop + coverage start"). + + Best-effort (T-34-11, accept-with-control): pandas may drop ``attrs`` on some + ops, so a frame without stamps yields ``source``/``valid_from`` of ``None`` + rather than raising. + + This callable is the public ``mostlyright.provenance`` surface. The Phase-32 + fetch-provenance LEDGER reads stay reachable as ATTRIBUTES on it + (``provenance.history`` / ``provenance.record`` / ``provenance.row_window_hash``) + so every ``mostlyright.provenance.history(...)`` call site keeps resolving. + """ + import pandas as pd + + attrs = getattr(frame, "attrs", {}) or {} + prov_map = attrs.get("provenance", {}) or {} + cov_map = attrs.get("coverage", {}) or {} + + rows = [ + {"column": col, "source": prov_map.get(col), "valid_from": cov_map.get(col)} + for col in frame.columns + ] + return pd.DataFrame(rows, columns=["column", "source", "valid_from"]) + + +# Back-compat: expose the ledger reads as attributes on the callable verb so +# ``mostlyright.provenance.history(...)`` (Phase 32 D-25) keeps resolving after +# ``mostlyright.provenance`` became the callable lineage verb (Phase 34 03). +provenance.history = history # type: ignore[attr-defined] +provenance.record = record # type: ignore[attr-defined] +provenance.row_window_hash = row_window_hash # type: ignore[attr-defined] + + +__all__ = ["history", "provenance", "record", "row_window_hash"] diff --git a/packages/core/src/mostlyright/align.py b/packages/core/src/mostlyright/align.py new file mode 100644 index 00000000..c2180ac7 --- /dev/null +++ b/packages/core/src/mostlyright/align.py @@ -0,0 +1,572 @@ +"""``mr.align(spine, *sources)`` — the frozen composition operator (Phase 34 01). + +``align`` as-of joins each source onto the spine and enforces the leakage guard +(``source.knowledge_time <= spine.decision_time``) in exactly ONE place for all +domains. Its signature is FROZEN at two concepts — ``(spine, *sources)`` — no +policy kwargs, ever (D-34.02). All policy (revision authority, pit-fidelity, +staleness) lives on the :class:`~mostlyright.contracts.SourceContract`, never on +``align``. The day ``align`` grows a ``tolerance=``/``fill=`` kwarg, the +god-function has been relocated, not killed. + +A "source" is a ``(frame, SourceContract)`` pair — a pre-materialized DataFrame +plus its bitemporal contract. The operator runs three independently-testable +pure stages per source: + +1. :func:`_collapse_revisions` — collapse append-only revisions to one row per + ``(entity_key, event_time_col)`` by the spec's ``revision_order`` authority + (STRICT ``>`` via first-seen tie). Reproduces the legacy ``merge_climate`` + dedup as a CONFORMING spec, NOT a special case here (the falsifier, D-34.03). +2. join — either a materialized equality join (when the spine already carries + the source's ``event_time_col`` — the reconstructed-label path) or a backward + as-of on ``knowledge_time`` (the ``<=`` boundary via + ``allow_exact_matches=True``). The join MODE is inferred from the spine's + shape, so ``align`` stays frozen at two concepts (no join kwarg). +3. :func:`_audit_source_leakage` — a PER-SOURCE leakage audit (R-22): each + source's ``knowledge_time`` is checked against the spine ``decision_time`` in + isolation, so a raised :class:`LeakageError` names WHICH source leaked and + never blames a clean co-aligned source. ``pit_fidelity="reconstructed"`` + sources are audit-exempt (knowingly not-PIT). + +Returns a plain ``pd.DataFrame`` (y columns by convention), stamped +``attrs["source"] = "align"`` — no subclass (pandas silently drops subclasses +on merge/concat; researchers asked for plain frames). + +PARITY FIREWALL: ``align`` is source-BLIND for the label aggregation and never +imports or touches the four CWOP firewall files or ``_internal/merge/climate.py`` +— it REPRODUCES the climate dedup via the spec, it does not modify it. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import pandas as pd + +# ``_FORBIDDEN_LABEL_PREFIXES`` is the 34-02 label firewall (``obs_``/``cli_``/...): +# a FEATURE source may NOT declare one. READ here, never loosened (R-09). +from mostlyright._contributors import _FORBIDDEN_LABEL_PREFIXES +from mostlyright._errmsg import contract_message, leakage_message + +# Phase 34 06: ``ContractError`` / ``LeakageError`` are imported LAZILY (inside the +# raising functions) rather than at module top. Importing ``mostlyright.core.exceptions`` +# eagerly triggers ``mostlyright.core.__init__`` → ``core.schemas`` → the satellite +# covariate schema; keeping it out of this module's import graph lets the root +# ``mostlyright.__init__`` eager-bind ``align`` (the headline verb) WITHOUT a bare +# ``import mostlyright`` pulling that schema (the flags-off import-cost guard). + +if TYPE_CHECKING: + from mostlyright.contracts import SourceContract + +__all__ = ["LazySourceSpec", "align"] + +#: The spine column carrying the point-in-time cutoff (D-34.19). +_DECISION_COL = "decision_time" +_SAMPLE_CAP = 10 + + +@dataclass(frozen=True) +class LazySourceSpec: + """A window-DEFERRED source call (R-02). + + A source builder invoked WITHOUT a window (e.g. ``weather.obs('KNYC')``) + returns this instead of eagerly fetching, so the pairs() desugaring + ``align(spine, weather.obs('KNYC'))`` never raises ``TypeError``. ``align`` is + the single place the fetch window is inferred — from the spine's copy of the + source's equality-join key (``contract.event_time_col``, e.g. the settlement + ``date``) when present, else the ``decision_time`` min/max — and it calls + :meth:`materialize` to turn the lazy spec into a concrete + ``(frame, SourceContract)`` source. + + Attributes: + contract: The :class:`~mostlyright.contracts.SourceContract` the + materialized frame conforms to (prefix / pit_fidelity / entity_key). + entity: The entity (station) the deferred builder will fetch. + builder: A callable ``(entity, from_date, to_date) -> pd.DataFrame`` that + re-enters the eager fetch path with the align-inferred window. The + ``from_date``/``to_date`` are inclusive ISO ``YYYY-MM-DD`` strings. + """ + + contract: SourceContract + entity: str | list[str] | tuple[str, ...] + builder: Callable[[str | list[str] | tuple[str, ...], str, str], pd.DataFrame] + + def materialize(self, from_date: str, to_date: str) -> tuple[pd.DataFrame, SourceContract]: + """Run the deferred builder over ``[from_date, to_date]`` (inclusive).""" + frame = self.builder(self.entity, from_date, to_date) + return frame, self.contract + + +def _collapse_revisions(frame: pd.DataFrame, spec: SourceContract) -> pd.DataFrame: + """Collapse append-only revisions to one row per ``(entity_key, event_time)``. + + For a spec with a non-empty ``revision_order``, sort by the declared authority + order then by original arrival index (first-seen tie-break) and keep the top + row per group — STRICT authority selection: a lower-authority revision that + arrives LATER still loses to an earlier higher-authority row (authority is + orthogonal to arrival time). Output groups are ordered by each key's FIRST + appearance, matching ``merge_climate``'s insertion order. + + An empty ``revision_order`` is the identity transform. + """ + if not spec.revision_order: + return frame + + group_cols = [*spec.entity_key, spec.event_time_col] + work = frame.reset_index(drop=True).copy() + work["__arrival"] = range(len(work)) + # First-appearance order of each key (merge_climate returns list(best.values()) + # in insertion order = first time each key was seen). + work["__first_app"] = work.groupby(group_cols, sort=False)["__arrival"].transform("min") + + sort_cols = [col for col, _ in spec.revision_order] + ascending = [direction == "asc" for _, direction in spec.revision_order] + # First-seen tie-break: at equal authority the earlier arrival wins (STRICT >). + sort_cols = [*sort_cols, "__arrival"] + ascending = [*ascending, True] + + ranked = work.sort_values(sort_cols, ascending=ascending, kind="mergesort") + winners = ranked.drop_duplicates(subset=group_cols, keep="first") + winners = winners.sort_values("__first_app", kind="mergesort") + return winners.drop(columns=["__arrival", "__first_app"]).reset_index(drop=True) + + +def _equality_join( + spine: pd.DataFrame, collapsed: pd.DataFrame, spec: SourceContract +) -> pd.DataFrame: + """Materialized LEFT equality join on ``(entity_key, event_time_col)``. + + Used when the spine already carries the source's ``event_time_col`` (the + reconstructed-label path): the source is already collapsed to one row per + settlement key, so a plain keyed LEFT join preserves every spine row. + """ + on = [*spec.entity_key, spec.event_time_col] + return spine.merge(collapsed, on=on, how="left", suffixes=("", f"_{spec.id}")) + + +def _asof_join( + spine: pd.DataFrame, + collapsed: pd.DataFrame, + spec: SourceContract, + kt_col: str | None = None, +) -> pd.DataFrame: + """Backward as-of join: latest ``knowledge_time <= decision_time`` per entity. + + ``allow_exact_matches=True`` IS the ``<=`` boundary convention; + ``direction="backward"`` selects the latest known row at or before the + cutoff; ``by=entity_key`` keeps per-entity panels isolated. ``kt_col`` is + the knowledge column's name AS IT APPEARS in ``collapsed`` — ``align`` + isolates temporal keys under per-source internal names before the join. + """ + kt = kt_col or spec.knowledge_time_col + by = list(spec.entity_key) + left = spine.sort_values(_DECISION_COL, kind="mergesort").reset_index(drop=True) + right = collapsed.sort_values(kt, kind="mergesort").reset_index(drop=True) + return pd.merge_asof( + left, + right, + left_on=_DECISION_COL, + right_on=kt, + by=by, + direction="backward", + allow_exact_matches=True, + ) + + +def _audit_source_leakage( + joined: pd.DataFrame, spec: SourceContract, kt_col: str | None = None +) -> None: + """Per-source leakage audit (R-22): raise if THIS source's ``knowledge_time`` + is after the spine ``decision_time``. + + Checks only THIS source's knowledge column against ``decision_time``, so the + raised :class:`LeakageError` attributes the leak to ``spec.id`` and a clean + co-aligned source is never blamed. ``kt_col`` is the knowledge column's name + AS IT APPEARS in ``joined`` — ``align`` isolates each source's temporal keys + under per-source internal names, so a second source sharing the same + declared ``knowledge_time_col`` can never hide from its own audit behind a + pandas-suffixed copy. The message is built through the shared ``_errmsg`` + helper (R-17). + """ + from mostlyright.core.exceptions import LeakageError + + kt = kt_col or spec.knowledge_time_col + if not kt or kt not in joined.columns or _DECISION_COL not in joined.columns: + return + + kcol = joined[kt] + dcol = joined[_DECISION_COL] + mask = kcol.notna() & (kcol > dcol) + n = int(mask.sum()) + if n == 0: + return + + sample = joined.loc[mask].head(_SAMPLE_CAP) + sample_violations: list[dict[str, object]] = [] + for idx, row in sample.iterrows(): + sample_violations.append( + { + "row_idx": int(idx), + "source": spec.id, + "knowledge_time": pd.Timestamp(row[kt]).isoformat(), + } + ) + + first = sample.iloc[0] + cutoff = pd.Timestamp(first[_DECISION_COL]).isoformat() + leaking = pd.Timestamp(first[kt]).isoformat() + raise LeakageError( + leakage_message( + source_id=spec.id, + decision_time=cutoff, + knowledge_time=leaking, + count=n, + ), + as_of=cutoff, + violating_count=n, + sample_violations=sample_violations, + source=spec.id, + ) + + +def _key_cols(spec: SourceContract) -> set[str]: + """Columns that are join KEYS (never namespaced): entity + event + knowledge.""" + keys = set(spec.entity_key) + if spec.event_time_col: + keys.add(spec.event_time_col) + if spec.knowledge_time_col: + keys.add(spec.knowledge_time_col) + return keys + + +def _coverage_edge(spec: SourceContract) -> str | None: + """The source's coverage left-edge (earliest declared ``valid_from``). + + Stamped per output column so an all-NaN column prefix is explainable ("NaN + before X: source coverage start", R-12). ``None`` when the source declares no + coverage — a legitimate "no declared coverage" value, not a stub. + """ + values = [v for v in spec.valid_from.values() if v] + return min(values) if values else None + + +def _gate_pit_fidelity(spec: SourceContract) -> None: + """Refuse a ``pit_fidelity="latest_only"`` source by default (R-18). + + ``latest_only`` (the FRED-default-API trap) serves the LATEST revision for + every as-of date, so a backtest silently sees values that did not exist at + ``decision_time`` — a vintage-leakage risk. Raise a :class:`ContractError` + (built via the shared ``_errmsg`` helper) naming the offending source, the + vintage risk, and the ``econ.vintages()``/ALFRED settlement-grade + alternative. A spec may opt into a loud warning instead via + ``pit_fidelity_on_latest_only="warn"`` (read through ``getattr`` — no new + required contract field). ``exact``/``reconstructed`` pass silently. + """ + if spec.pit_fidelity != "latest_only": + return + + from mostlyright.core.exceptions import ContractError + + mode = getattr(spec, "pit_fidelity_on_latest_only", "refuse") + message = contract_message( + source_id=spec.id, + field="pit_fidelity", + problem=( + "pit_fidelity='latest_only' presents a vintage-leakage risk — the " + "latest revision is served for every as-of date, so a backtest sees " + "values that did not exist at decision_time" + ), + fix=( + "use a point-in-time source; for econ/FRED series call econ.vintages() " + "(ALFRED-backed) for the settlement-grade vintaged frame" + ), + ) + if mode == "warn": + import warnings + + warnings.warn(message, UserWarning, stacklevel=3) + return + raise ContractError(message, field="pit_fidelity", source=spec.id) + + +def _apply_prefix( + frame: pd.DataFrame, + spec: SourceContract, + prefix_override: str | None, + contributing: dict[str, str], + coverage: dict[str, str | None], +) -> pd.DataFrame: + """Namespace a FEATURE source's non-key columns under its declared prefix. + + Kind-aware (the align-layer echo of 34-02's source-only firewall): a + ``pit_fidelity="reconstructed"`` frame IS the LABEL — it owns its column names + by construction (e.g. the CLI settlement label composed by ``research()``), so + it is byte-PASSED-THROUGH unchanged (no namespacing, no forbidden-prefix + firewall), still claiming its columns in ``contributing`` so a later FEATURE + source cannot silently overwrite them. + + For a FEATURE source (``exact``/``latest_only``) two firewalls run: + + 1. R-09: the declared prefix (override or ``spec.prefix``) MUST NOT be one of + ``_FORBIDDEN_LABEL_PREFIXES`` — a source may not masquerade as a LABEL. + 2. R-24: after prefixing each non-key column to ``{prefix}{col}``, if the + running result already owns that target column (a DIFFERENT source), raise + a :class:`ContractError` naming BOTH source ids, the colliding column, AND + which source's ``prefix=`` to change. pandas ``_x``/``_y`` NEVER appear. + + Registers every produced column → ``spec.id`` in ``contributing`` and → + the source coverage edge in ``coverage`` (for the provenance stamp). + """ + from mostlyright.core.exceptions import ContractError + + keys = _key_cols(spec) + edge = _coverage_edge(spec) + + # Reconstructed LABEL: owns its columns → pass through unchanged, but register. + if spec.pit_fidelity == "reconstructed": + for col in frame.columns: + if col in keys: + continue + _register(col, spec, contributing, coverage, edge, colliding_source=spec.id) + return frame + + prefix = prefix_override or spec.prefix + + # R-09 firewall: a FEATURE source must not claim a LABEL-only namespace. + if any(prefix == fp or prefix.startswith(fp) for fp in _FORBIDDEN_LABEL_PREFIXES): + raise ContractError( + contract_message( + source_id=spec.id, + field="prefix", + problem=( + f"prefix {prefix!r} is a reserved LABEL-only namespace " + f"(one of {sorted(_FORBIDDEN_LABEL_PREFIXES)}) — a feature " + f"source may not masquerade as a settlement label" + ), + fix=( + "choose a source-owned prefix (e.g. the source short id like 'met_' / 'cwop_')" + ), + ), + field="prefix", + source=spec.id, + ) + + renames: dict[str, str] = {} + for col in frame.columns: + if col in keys: + continue + target = col if col.startswith(prefix) else f"{prefix}{col}" + _register(target, spec, contributing, coverage, edge, colliding_source=spec.id) + renames[col] = target + + return frame.rename(columns=renames) if renames else frame + + +def _register( + column: str, + spec: SourceContract, + contributing: dict[str, str], + coverage: dict[str, str | None], + edge: str | None, + *, + colliding_source: str, +) -> None: + """Claim ``column`` for ``spec.id``; raise a naming ContractError on collision (R-24).""" + from mostlyright.core.exceptions import ContractError + + owner = contributing.get(column) + if owner is not None and owner != spec.id: + raise ContractError( + contract_message( + source_id=spec.id, + field="prefix", + problem=( + f"column {column!r} collides with source {owner!r} — both " + f"sources emit {column!r} after prefixing" + ), + fix=( + f"set a distinct prefix= on source {spec.id!r} or {owner!r} so " + f"their columns land in separate namespaces" + ), + ), + field="prefix", + source=spec.id, + ) + contributing[column] = spec.id + coverage[column] = edge + + +def align( + spine: pd.DataFrame, + *sources: tuple[pd.DataFrame, SourceContract] | LazySourceSpec, +) -> pd.DataFrame: + """As-of join each source onto ``spine``. + + Signature FROZEN at two concepts (D-34.02): ``(spine, *sources)`` — no policy + kwargs. A source is EITHER a pre-materialized ``(frame, SourceContract)`` pair + (BYO / rung-1; an optional 3rd tuple element overrides the prefix) OR a + :class:`LazySourceSpec` — a window-deferred builder call that ``align`` + materializes over the spine's settlement-day key (the source's + ``event_time_col``) when the spine carries it, else the ``decision_time`` + min/max (R-02). + + Per source: refuse ``latest_only`` (R-18) → collapse by ``revision_order`` + authority → namespace non-key columns under the source prefix with a hard + collision ``ContractError`` (R-09/R-24, never pandas ``_x``/``_y``) → join + (equality or backward as-of, inferred from the spine's shape) → per-source + leakage audit (R-22). Returns a plain ``pd.DataFrame`` stamped + ``attrs["source"] = "align"``, plus per-column ``attrs["provenance"]`` (source + id) and ``attrs["coverage"]`` (source ``valid_from``/coverage edge) so an + all-NaN column prefix is explainable via ``mr.provenance(frame)``. + """ + result = spine.copy() + contributing: dict[str, str] = {} + coverage: dict[str, str | None] = {} + for source in sources: + prefix_override: str | None = None + if isinstance(source, LazySourceSpec): + _gate_pit_fidelity(source.contract) + from_date, to_date = _spine_window(spine, source.contract) + frame, spec = source.materialize(from_date, to_date) + else: + frame, spec = source[0], source[1] + prefix_override = source[2] if len(source) > 2 else None + _gate_pit_fidelity(spec) + + # A contract that DECLARES columns must fail loudly when the frame is + # missing one — never a silently-absent promised feature column. + _validate_declared_columns(frame, spec) + + collapsed = _collapse_revisions(frame, spec) + prefixed = _apply_prefix(collapsed, spec, prefix_override, contributing, coverage) + + # Isolate THIS source's temporal keys under per-source internal names + # before the join: a second source sharing the same knowledge/event + # column name must neither collide into pandas ``_x``/``_y`` suffixes + # nor hide its knowledge column from its own leakage audit. + equality = bool(spec.event_time_col) and spec.event_time_col in result.columns + kt_col = spec.knowledge_time_col + isolated: list[tuple[str, str]] = [] + # A knowledge column that doubles as THIS source's join key (e.g. the + # obs contract's knowledge_time_col == event_time_col == "date" on the + # equality path) must keep its name — the join consumes it directly. + own_join_keys = {*spec.entity_key} + if equality: + own_join_keys.add(spec.event_time_col) + if kt_col and kt_col in prefixed.columns and kt_col not in own_join_keys: + internal_kt = _free_internal_name(f"__mr_kt_{spec.id}", result, prefixed) + prefixed = prefixed.rename(columns={kt_col: internal_kt}) + isolated.append((internal_kt, kt_col)) + kt_col = internal_kt + if not equality and spec.event_time_col and spec.event_time_col in prefixed.columns: + internal_et = _free_internal_name(f"__mr_et_{spec.id}", result, prefixed) + prefixed = prefixed.rename(columns={spec.event_time_col: internal_et}) + isolated.append((internal_et, spec.event_time_col)) + + if equality: + result = _equality_join(result, prefixed, spec) + else: + result = _asof_join(result, prefixed, spec, kt_col) + # pit_fidelity="reconstructed" sources are knowingly not point-in-time + # (highest-authority-available, e.g. the CLI settlement label) → exempt. + if spec.pit_fidelity != "reconstructed": + _audit_source_leakage(result, spec, kt_col) + + # Restore each isolated key to its declared name when free; a name a + # prior source already claimed is dropped (never suffixed). + for internal, original in isolated: + if internal not in result.columns: + continue + if original in result.columns: + result = result.drop(columns=[internal]) + else: + result = result.rename(columns={internal: original}) + + result.attrs["source"] = "align" + result.attrs["provenance"] = dict(contributing) + result.attrs["coverage"] = dict(coverage) + return result + + +def _free_internal_name(base: str, *frames: pd.DataFrame) -> str: + """Return ``base`` (or ``base_N``) guaranteed absent from every frame. + + The temporal-key isolation names must never collide with a real column — a + spine that already carries ``__mr_kt_`` would otherwise re-open the + pandas-suffix hole the isolation exists to close (review-iter-2). + """ + taken: set[str] = set() + for frame in frames: + taken.update(frame.columns) + name = base + counter = 0 + while name in taken: + counter += 1 + name = f"{base}_{counter}" + return name + + +def _validate_declared_columns(frame: pd.DataFrame, spec: SourceContract) -> None: + """Fail loudly when a source frame is missing a column its contract declares. + + Scoped to what ``align`` itself consumes — the ``entity_key``, the declared + temporal keys, and the declared feature ``columns`` (a silently-absent + promised feature column was review-iter-1 HIGH). BYO contracts stay minimal + (34-03): only columns the contract actually DECLARES are required; the + registration-level completeness rules (``validate_source_contract``) do NOT + run here. + """ + from mostlyright.core.exceptions import ContractError + + required = [ + col + for col in ( + *spec.entity_key, + spec.event_time_col, + spec.knowledge_time_col, + *spec.columns, + ) + if col + ] + present = set(frame.columns) + for col in required: + if col not in present: + raise ContractError( + contract_message( + source_id=spec.id, + field=col, + problem=f"the source frame is missing its declared column {col!r}", + fix=( + f"add the {col!r} column to the frame " + f"(or fix the contract's declared columns)" + ), + ), + field=col, + source=spec.id, + ) + + +def _spine_window(spine: pd.DataFrame, spec: SourceContract) -> tuple[str, str]: + """Infer an inclusive ISO ``YYYY-MM-DD`` window for a window-less source. + + When the spine carries the source's equality-join key + (``spec.event_time_col``, e.g. the settlement ``date``), the window is that + column's min/max — the exact days the join will attach to. Falling back to + ``decision_time.date()`` is only safe for as-of sources: ``decision_time`` + is a market-close INSTANT (16:30 LST), which crosses UTC midnight at + Pacific/Alaska/Hawaii stations, so its UTC calendar date is ``settlement + date + 1`` and a date-derived window would silently drop the first + settlement day's features (review-iter-1 KLAX regression). + """ + et = spec.event_time_col + if et and et in spine.columns: + col = pd.to_datetime(spine[et]) + return ( + pd.Timestamp(col.min()).date().isoformat(), + pd.Timestamp(col.max()).date().isoformat(), + ) + col = spine[_DECISION_COL] + lo = pd.Timestamp(col.min()).date().isoformat() + hi = pd.Timestamp(col.max()).date().isoformat() + return lo, hi diff --git a/packages/core/src/mostlyright/contracts/__init__.py b/packages/core/src/mostlyright/contracts/__init__.py new file mode 100644 index 00000000..88768165 --- /dev/null +++ b/packages/core/src/mostlyright/contracts/__init__.py @@ -0,0 +1,32 @@ +"""``mr.contracts`` — spine + source contract shapes / validators (demoted). + +Phase 34 01 seeded this package with the bitemporal :class:`SourceContract`. +Phase 34 02 completes the demoted surface: the labeled-target :class:`SpineContract`, +the shared contract/frame validators (:func:`validate_source_contract`, +:func:`validate_source_frame`, :func:`validate_spine_frame`), and a re-export of the +:class:`~mostlyright.core.temporal.timepoint.TimePoint` temporal primitive the whole +contract layer is seeded from. + +This surface is author-facing (met via error messages, not researcher +autocomplete) — hence demoted out of the root namespace per the cut rule. +""" + +from __future__ import annotations + +from mostlyright.contracts._source import SourceContract +from mostlyright.contracts._spine import ( + SpineContract, + validate_source_contract, + validate_source_frame, + validate_spine_frame, +) +from mostlyright.core.temporal.timepoint import TimePoint + +__all__ = [ + "SourceContract", + "SpineContract", + "TimePoint", + "validate_source_contract", + "validate_source_frame", + "validate_spine_frame", +] diff --git a/packages/core/src/mostlyright/contracts/_source.py b/packages/core/src/mostlyright/contracts/_source.py new file mode 100644 index 00000000..4fc000b2 --- /dev/null +++ b/packages/core/src/mostlyright/contracts/_source.py @@ -0,0 +1,102 @@ +"""Bitemporal source contract (Phase 34 01, D-34.05). + +:class:`SourceContract` is the frozen dataclass that declares how ONE source +frame joins into the guarded ``align(spine, *sources)`` world. It carries the +full bitemporal field list up front — the contract is the expensive-to-change +part of the design (34-CONTEXT over-engineering guard: design it NOW, in full), +while the registry MACHINERY around it stays minimal until vertical #3. + +The load-bearing field is ``revision_order``: a tuple of ``(column, "asc"|"desc")`` +pairs plus a ``revision_tie`` default of ``"first_seen"``. Together they let the +``align`` collapse stage reproduce the legacy climate dedup +(``report_type_priority`` STRICT ``>``, first-seen-wins) as a CONFORMING source +spec — NOT a special case inside ``align()`` (the phase falsifier, D-34.03). + +Mirrors the frozen-dataclass style + prefix-ends-underscore convention of +``ContributorSpec`` (``_contributors.py``). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Literal + +from mostlyright._errmsg import contract_message +from mostlyright.core.exceptions import ContractError + +PitFidelity = Literal["exact", "reconstructed", "latest_only"] +Availability = Literal["py", "ts", "py,ts"] + + +@dataclass(frozen=True) +class SourceContract: + """Immutable bitemporal contract for one source frame. + + Attributes: + id: Short source identity (e.g. ``"weather.climate.cli"``). Named in + every error the source's rows trigger (leakage attribution, R-22). + prefix: Column namespace this source owns; MUST end with ``"_"`` so a + collision check is an unambiguous string-prefix test. A bad prefix + raises :class:`ContractError` at construction. + event_time_col: The column holding the row's event/observation time. + When the spine already carries this column, ``align`` uses a + materialized equality join (the reconstructed-label path); otherwise + it uses a backward as-of on ``knowledge_time_col``. + knowledge_time_col: The column holding when the row became KNOWN. The + leakage guard compares this against the spine's ``decision_time``. + entity_key: The per-entity key tuple joins group by (``by=`` in + ``merge_asof``); default ``("station",)``. + revision_order: Tuple of ``(column, "asc"|"desc")`` authority pairs. The + collapse stage sorts by this order then keeps the top row per + ``(entity_key, event_time_col)`` group. Empty → collapse is identity. + revision_tie: Tie-break at equal authority; ``"first_seen"`` (STRICT + ``>`` semantics — the second equal row never overwrites the first). + pit_fidelity: ``"exact"`` (true point-in-time, leakage-audited), + ``"reconstructed"`` (highest-authority-available, knowingly NOT PIT + → audit-exempt, e.g. the CLI settlement label), or ``"latest_only"`` + (align refuses / loudly warns — Plan 03). + units: Per-column unit strings (frozen). + license: Redistribution/resale-rights flag; default ``"unknown"``. + valid_from: Per-entity coverage left-edge (frozen). + native_frequency: Declared native cadence; default ``"daily"``. + availability: Runtime availability ``"py"`` / ``"ts"`` / ``"py,ts"``. + schema_id: Registered ``schema.*.v1`` id (may be empty for a spike spec). + schema_version: Schema version integer. + columns: The declared feature columns this source emits. + """ + + id: str + prefix: str + event_time_col: str = "" + knowledge_time_col: str = "" + entity_key: tuple[str, ...] = ("station",) + revision_order: tuple[tuple[str, str], ...] = () + revision_tie: str = "first_seen" + pit_fidelity: PitFidelity = "reconstructed" + units: MappingProxyType[str, str] = field(default_factory=lambda: MappingProxyType({})) + license: str = "unknown" + valid_from: MappingProxyType[str, str] = field(default_factory=lambda: MappingProxyType({})) + native_frequency: str = "daily" + availability: Availability = "py" + schema_id: str = "" + schema_version: int = 1 + columns: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.prefix, str) or not self.prefix.endswith("_"): + raise ContractError( + contract_message( + source_id=self.id, + field="prefix", + problem=( + f"prefix must be a non-empty string ending with '_' (got {self.prefix!r})" + ), + fix="set prefix to a column namespace ending in '_' (e.g. 'obs_')", + ), + field="prefix", + source=self.id, + ) + # Freeze the mapping fields so the frozen dataclass is deeply immutable. + object.__setattr__(self, "units", MappingProxyType(dict(self.units))) + object.__setattr__(self, "valid_from", MappingProxyType(dict(self.valid_from))) diff --git a/packages/core/src/mostlyright/contracts/_spine.py b/packages/core/src/mostlyright/contracts/_spine.py new file mode 100644 index 00000000..b520def4 --- /dev/null +++ b/packages/core/src/mostlyright/contracts/_spine.py @@ -0,0 +1,193 @@ +"""Spine contract + contract/frame validators (Phase 34 02, D-34.06). + +The :class:`SpineContract` is the LABELED-TARGET axis of the two-axis +``align(spine, *sources)`` world: it declares the entity key, the ``decision_time`` +cutoff column, the ``label_available_time`` column (when the target became known), +the plural ``y_columns`` (a spine may carry several targets — high AND low), and +the label's source-identity + version. It is the mirror of :class:`SourceContract` +(the FEATURE axis): the source contract says "here is a feature frame and how it +joins without leaking"; the spine contract says "here is the target frame the +features are aligned against". + +This module also holds the three validators the registry + composer share: + +- :func:`validate_source_contract` — spec validation (bitemporal fields present, + known ``pit_fidelity``, prefix ends ``"_"``). Reused by ``mr.registry.source()`` + / ``mr.registry.label()`` so a nonconforming spec is rejected at registration. +- :func:`validate_source_frame` — a source DataFrame carries its declared columns. +- :func:`validate_spine_frame` — a spine DataFrame carries its declared columns. + +Every rejection is a :class:`ContractError` whose copy is built via the shared +``_errmsg.contract_message`` helper (R-17), so the wording is uniform with the +rest of the phase's contract errors. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from mostlyright._errmsg import contract_message +from mostlyright.contracts._source import SourceContract +from mostlyright.core.exceptions import ContractError + +if TYPE_CHECKING: + import pandas as pd + +#: The pit_fidelity values a source contract may declare (mirrors +#: ``_source.PitFidelity``). ``"latest_only"`` is accepted by the contract but +#: ``align`` refuses / loudly warns on it (Plan 03). +_VALID_PIT_FIDELITY: tuple[str, ...] = ("exact", "reconstructed", "latest_only") + + +@dataclass(frozen=True) +class SpineContract: + """Immutable contract for the labeled-target (y) axis of ``align``. + + Attributes: + y_columns: The target column(s) the spine carries (e.g. + ``("y_high_f", "y_low_f")``). A spine with no target is meaningless — + an empty tuple raises :class:`ContractError` at construction. + label_source_id: The source identity of the label (e.g. + ``"weather.label.cli"``) — named in leakage / mismatch errors. + entity_key: The per-entity key tuple the spine groups by; default + ``("station",)``. + decision_time_col: The column holding the per-row decision cutoff (the + "as of" a model would have traded); default ``"decision_time"``. + label_available_time_col: The column holding when the label became KNOWN + (published); default ``"label_available_time"``. + label_version: Monotonic label-recipe version; default ``1``. A bump + signals the settlement label's computation changed. + """ + + y_columns: tuple[str, ...] + label_source_id: str + entity_key: tuple[str, ...] = ("station",) + decision_time_col: str = "decision_time" + label_available_time_col: str = "label_available_time" + label_version: int = 1 + + def __post_init__(self) -> None: + if not self.y_columns: + raise ContractError( + contract_message( + source_id=self.label_source_id or "", + field="y_columns", + problem="a spine contract must declare at least one y (target) column", + fix="pass y_columns=('your_target',) — a spine with no target is meaningless", + ), + field="y_columns", + source=self.label_source_id or None, + ) + + +def validate_source_contract(spec: SourceContract) -> None: + """Validate a :class:`SourceContract` spec (bitemporal + pit_fidelity + prefix). + + Raised as a :class:`ContractError` naming the offending field. Reused by + ``mr.registry.source()`` / ``mr.registry.label()`` so a nonconforming spec is + rejected at registration time (the ``prefix`` end-``"_"`` rule is already + enforced in :meth:`SourceContract.__post_init__`; re-checked here for the + direct-validation path). + """ + if not spec.event_time_col: + raise ContractError( + contract_message( + source_id=spec.id, + field="event_time_col", + problem="a source contract must declare event_time_col (the row's event/observation time)", + fix="set event_time_col to the timestamp column your frame carries", + ), + field="event_time_col", + source=spec.id, + ) + if not spec.knowledge_time_col: + raise ContractError( + contract_message( + source_id=spec.id, + field="knowledge_time_col", + problem="a source contract must declare knowledge_time_col (when the row became known)", + fix="set knowledge_time_col to the column the leakage guard compares to decision_time", + ), + field="knowledge_time_col", + source=spec.id, + ) + if spec.pit_fidelity not in _VALID_PIT_FIDELITY: + raise ContractError( + contract_message( + source_id=spec.id, + field="pit_fidelity", + problem=f"unknown pit_fidelity {spec.pit_fidelity!r} (valid: {_VALID_PIT_FIDELITY})", + fix="use one of 'exact' / 'reconstructed' / 'latest_only'", + ), + field="pit_fidelity", + source=spec.id, + ) + if not isinstance(spec.prefix, str) or not spec.prefix.endswith("_"): + raise ContractError( + contract_message( + source_id=spec.id, + field="prefix", + problem=f"prefix must be a non-empty string ending with '_' (got {spec.prefix!r})", + fix="set prefix to a column namespace ending in '_' (e.g. 'met_')", + ), + field="prefix", + source=spec.id, + ) + + +def _require_columns( + df: pd.DataFrame, + required: tuple[str, ...], + *, + source_id: str, + kind: str, +) -> None: + """Raise a ContractError naming the FIRST declared column missing from ``df``.""" + present = set(df.columns) + for col in required: + if col not in present: + raise ContractError( + contract_message( + source_id=source_id, + field=col, + problem=f"the {kind} frame is missing its declared column {col!r}", + fix=f"add the {col!r} column to the frame (or fix the contract's declared columns)", + ), + field=col, + source=source_id, + ) + + +def validate_source_frame(df: pd.DataFrame, contract: SourceContract) -> None: + """Validate that a source DataFrame carries its contract's declared columns. + + Checks the bitemporal timestamp columns (``event_time_col`` / + ``knowledge_time_col``), the ``entity_key``, and the declared feature + ``columns``. Also sanity-checks the contract's ``pit_fidelity`` is a known + value. Raises :class:`ContractError` naming the first missing column. + """ + validate_source_contract(contract) + required = ( + contract.event_time_col, + contract.knowledge_time_col, + *contract.entity_key, + *contract.columns, + ) + _require_columns(df, required, source_id=contract.id, kind="source") + + +def validate_spine_frame(df: pd.DataFrame, contract: SpineContract) -> None: + """Validate that a spine DataFrame carries its contract's declared columns. + + Checks the ``entity_key``, ``decision_time_col``, ``label_available_time_col``, + and every ``y_columns`` entry. Raises :class:`ContractError` naming the first + missing column. + """ + required = ( + *contract.entity_key, + contract.decision_time_col, + contract.label_available_time_col, + *contract.y_columns, + ) + _require_columns(df, required, source_id=contract.label_source_id, kind="spine") diff --git a/packages/core/src/mostlyright/core/exceptions.py b/packages/core/src/mostlyright/core/exceptions.py index c81b492c..f1717419 100644 --- a/packages/core/src/mostlyright/core/exceptions.py +++ b/packages/core/src/mostlyright/core/exceptions.py @@ -27,6 +27,7 @@ __all__ = [ "DATA_AVAILABILITY_REASONS", "CaptureNotAvailable", + "ContractError", "DataAvailabilityError", "DataAvailabilityReason", "DeprecatedModelWarning", @@ -45,6 +46,7 @@ "LiveStreamError", "MostlyRightError", "NoCWOPDataError", + "NoDataError", "NoLiveDataError", "NoLiveForNwpError", "NwpError", @@ -288,6 +290,55 @@ def _payload(self) -> dict[str, Any]: return payload +class ContractError(MostlyRightError): + """A frame or source spec violates its declared contract (Phase 34 D-34.04). + + Modelled on :class:`SchemaValidationError` but for the two-axis + ``align(spine, *sources)`` world: raised when a :class:`~mostlyright.contracts.SourceContract` + is malformed (e.g. a ``prefix`` that does not end with ``"_"``) or when a + source/spine frame does not conform at compose time. ``field`` names the + offending contract field so the copy (built via + :func:`mostlyright._errmsg.contract_message`) points the user at the exact + thing to change. + """ + + default_error_code = "CONTRACT_ERROR" + + def __init__( + self, + message: str = "", + *, + field: str | None = None, + source: str | None = None, + request_id: str | None = None, + error_code: str | None = None, + ) -> None: + super().__init__( + message, + error_code=error_code, + source=source, + request_id=request_id, + ) + self.field: str | None = field + + def _payload(self) -> dict[str, Any]: + payload = super()._payload() + payload.update(field=self.field) + return payload + + +class NoDataError(MostlyRightError): + """No usable data was produced for the requested entity/window (D-34.04). + + The taxonomy base for "I asked and got nothing" across domains. The existing + ``NoLiveDataError`` / ``NoCWOPDataError`` are re-parented under this class in + Plan 06; this plan only introduces the base so align/contract code and Plans + 02-06 can build on a stable name. + """ + + default_error_code = "NO_DATA" + + class SourceMismatchError(MostlyRightError): """The data's source does not match the schema's registered source, and the caller did not opt out via ``allow_source_drift``. ``role`` (if set) @@ -824,7 +875,7 @@ def _payload(self) -> dict[str, Any]: # ---------------------------------------------------------------------- -class LiveStreamError(MostlyRightError): +class LiveStreamError(NoDataError): """Base class for ``mostlyright.live.stream`` / ``live.latest`` failures. Live-streaming errors are deliberately a separate sub-tree from @@ -834,6 +885,12 @@ class LiveStreamError(MostlyRightError): only raised by the one-shot :func:`mostlyright.live.latest` surface; :func:`mostlyright.live.stream` swallows empty-tick errors and waits for the next polite-floor cycle. + + Phase 34 06 (D-34.20 error-taxonomy collapse): re-parented under + :class:`NoDataError` so ``NoLiveDataError`` / ``NoCWOPDataError`` live in the + ``NoDataError`` sub-tree WITHOUT losing their ``LiveStreamError`` ancestry + (an additive MRO change — nothing is dropped, consistent with the 1.x + additive-plus-shims rule). ``default_error_code`` stays ``LIVE_STREAM_ERROR``. """ default_error_code = "LIVE_STREAM_ERROR" diff --git a/packages/core/src/mostlyright/discover.py b/packages/core/src/mostlyright/discover.py index fe2a09ee..605ab663 100644 --- a/packages/core/src/mostlyright/discover.py +++ b/packages/core/src/mostlyright/discover.py @@ -1,48 +1,301 @@ -"""Phase 10 — ``discover()`` ergonomic surface. +"""Cross-domain catalog search — ``discover(text?, **filters)`` (Phase 34 05). -Pre-research lookup that shows quants which station settles which -issuer's market for a given city. Use this BEFORE picking the right -selector for ``research()`` — especially for cross-issuer cities like -NYC where Kalshi settles against KNYC and Polymarket against KLGA. +Generalizes the Phase-10 ``discover(*, city=)`` (city → station → settles_for) into a +cross-domain search over the registration metadata built in Plan 02 +(``registry.entries()``). The registration metadata IS the search index: id, kind +(source/label), domain, columns, units, availability (``py``/``ts``), license, PIT +fidelity. Scoring is DETERMINISTIC field-weighted token matching +(id/title > tags > columns > description) — NO fuzzy/semantic search on the local +path (determinism = scriptability; hosted fuzzy is a future paid-tier opt-in). + +Three entry rungs, all one function: + +- **Zero-arg** ``discover()`` → the full catalog (every registry entry), deterministic + order — the "show me everything" listing (R-23). +- **Structured filters** ``discover(domain="weather", kind="source")`` → the primary, + scriptable interface. +- **Free text** ``discover("nyc temperature")`` → sugar: known entity aliases in the + query (``nyc``) fire the entity alias resolver (pairing dataset x entity, carrying + the cross-venue settlement ``note``), and the remaining tokens (``temperature``) + feed the deterministic scorer (R-05). + +A guaranteed-miss free-text query returns an empty-but-shaped DataFrame whose +``df.attrs`` carries a guiding message (R-06). Keyword-only ``city=`` keeps working +(routes to the resolver path — backward compatible). Every result row carries a +runnable, rung-appropriate, language-tagged ``{py, ts}`` ``usage`` value. """ from __future__ import annotations +import re from typing import TYPE_CHECKING, Any if TYPE_CHECKING: import pandas as pd + from mostlyright.contracts._source import SourceContract + +# NOTE: ``registry`` / ``registry._catalog`` are imported LAZILY inside functions +# (never at module top). ``mostlyright/__init__`` imports ``discover`` eagerly, and +# ``registry`` transitively pulls the ``contracts`` → satellite-schema chain — a +# top-level import here would make a flags-off ``import mostlyright`` load the +# satellite covariate schema (test_dataset_covariates import-cost guard). Keep this +# module's import graph stdlib-only. + +#: Field weights for the deterministic four-tier scorer: id/title outranks tags, +#: tags outrank columns, columns outrank the description. Pure token matching. +_WEIGHT_ID = 4.0 +_WEIGHT_TAGS = 3.0 +_WEIGHT_COLUMNS = 2.0 +_WEIGHT_DESC = 1.0 + +_TOKEN_RE = re.compile(r"[a-z0-9]+") + + +def _tokenize(text: str) -> list[str]: + """Lower-case tokenizer: split on any non-alphanumeric run. Deterministic.""" + return _TOKEN_RE.findall(text.lower()) + + +# ---------------------------------------------------------------------- +# Weather entity alias resolver — generalizes _compose.resolve_city / +# annotate_settles_for, preserving the cross-venue settlement note (T-34-17). +# ---------------------------------------------------------------------- +class _WeatherEntityResolver: + """Resolve a city alias (``"nyc"``) to its settlement stations + cross-venue note. + + Wraps the Phase-10 ``_compose.resolve_city`` / ``annotate_settles_for`` cross-issuer + logic. Returns ``{"domain": "weather", "entities": [...], "note": ...}`` where the + ``note`` preserves the KNYC-vs-KLGA cross-venue settlement warning when venues + settle a city at different stations. + """ + + _vocab: set[str] | None = None + + def __call__(self, alias: str) -> dict[str, Any] | None: + from mostlyright._compose import annotate_settles_for, resolve_city + + try: + stations = resolve_city(alias) + except (ValueError, ModuleNotFoundError): + return None + venue_station: dict[str, str] = {} + entities: list[dict[str, Any]] = [] + for station in stations: + markers = annotate_settles_for(station, alias) + entities.append({"entity": station, "markers": markers}) + for marker in markers: + venue_station.setdefault(marker.split(":", 1)[0], station) + note = "" + if len(venue_station) >= 2 and len(set(venue_station.values())) >= 2: + parts = ", ".join(f"{v}→{s}" for v, s in sorted(venue_station.items())) + note = ( + f"cross-venue settlement ({parts}): a city settles at different stations " + f"per venue — pick the station matching YOUR venue " + f"(e.g. Kalshi {venue_station.get('kalshi', '?')} vs " + f"Polymarket {venue_station.get('polymarket', '?')})" + ) + for entity in entities: + entity["note"] = note if (note and entity["markers"]) else "" + return {"domain": "weather", "entities": entities, "note": note} + + def aliases(self) -> set[str]: + """The alias tokens ``discover()`` extracts from free text (city slugs).""" + from mostlyright._compose import ( + _CITY_SLUG_ALIASES, + _CITY_SLUG_ALIASES_REVERSE, + ) + + return set(_CITY_SLUG_ALIASES) | set(_CITY_SLUG_ALIASES_REVERSE) + + def vocabulary(self) -> set[str]: + """Resolvable entity ids (station codes) for the deterministic nearest-match.""" + if self._vocab is None: + from mostlyright._compose import _CITY_SLUG_ALIASES, resolve_city + + stations: set[str] = set() + for slug in _CITY_SLUG_ALIASES: + try: + stations.update(resolve_city(slug)) + except Exception: # pragma: no cover - vocab build is best-effort + continue + self._vocab = stations + return self._vocab + + +_WEATHER_RESOLVER = _WeatherEntityResolver() + + +def _ensure_resolvers() -> None: + """Register the built-in entity alias resolvers (idempotent). + + Called lazily from :func:`discover` and from + ``registry._ensure_default_resolvers`` — NEVER at module import (see the + import-cost note at the top of this module). + """ + from mostlyright import registry + + registry.register_alias_resolver("weather", _WEATHER_RESOLVER) + + +def _is_known_alias(token: str) -> bool: + """Whether ``token`` is a registered entity alias (routes to the resolver path).""" + from mostlyright import registry -def discover(*, city: str) -> pd.DataFrame: - """Return per-station discovery table for ``city``. + for resolver in registry._ALIAS_RESOLVERS.values(): + aliases = getattr(resolver, "aliases", None) + if callable(aliases) and token.lower() in aliases(): + return True + return False - Each row shows one settlement station + the list of ``":"`` - markers that resolve against it. Stations in the per-city Polymarket - denylist also appear (with empty ``settles_for``) so quants see the - full station neighborhood before deciding whether to use - ``station_override=``. + +# ---------------------------------------------------------------------- +# Scoring + filtering over the registration-metadata index +# ---------------------------------------------------------------------- +def _score(tokens: list[str], record: dict[str, Any], contract: SourceContract) -> float: + """Deterministic field-weighted token score (id > tags > columns > description). + + Per token, take the MAX weight among the fields it appears in; sum across tokens. + Pure string membership — NO fuzzy / substring / semantic matching. + """ + id_toks = set(_tokenize(record["id"])) + tags = set(_tokenize(record["domain"])) + tags.add(str(record["kind"])) + tags.update(_tokenize(contract.prefix.rstrip("_"))) + tags.add(str(contract.pit_fidelity)) + tags.update(_tokenize(str(contract.schema_id))) + col_toks: set[str] = set() + for col in record["columns"]: + col_toks.update(_tokenize(str(col))) + desc = set(_tokenize(str(record["license"]))) + for value in record["units"].values(): + desc.update(_tokenize(str(value))) + desc.update(_tokenize(str(record["availability"]))) + desc.update(_tokenize(str(contract.native_frequency))) + + tiers = ( + (_WEIGHT_ID, id_toks), + (_WEIGHT_TAGS, tags), + (_WEIGHT_COLUMNS, col_toks), + (_WEIGHT_DESC, desc), + ) + total = 0.0 + for token in tokens: + best = 0.0 + for weight, field in tiers: + if token in field: + best = max(best, weight) + total += best + return total + + +def _passes_filters(record: dict[str, Any], filters: dict[str, Any]) -> bool: + """Apply structured filters (domain / kind / availability / license / pit_fidelity).""" + for key, value in filters.items(): + if key == "availability": + if value not in str(record["availability"]).split(","): + return False + elif record.get(key) != value: + return False + return True + + +def _catalog_rows(tokens: list[str] | None, filters: dict[str, Any]) -> list[dict[str, Any]]: + """Score + filter the registration-metadata index into result records.""" + from mostlyright import _contributors as _contrib + from mostlyright import registry + from mostlyright.registry import _catalog + + out: list[dict[str, Any]] = [] + for id, contract in registry.entries().items(): + spec = _contrib._REGISTRY.get(id) + kind = spec.kind if spec is not None else "source" + domain = id.split(".", 1)[0] + installed = _catalog._domain_installed(domain) + record = _catalog.record_from_contract(id, contract, kind, installed=installed) + if not _passes_filters(record, filters): + continue + if tokens: + score = _score(tokens, record, contract) + if score <= 0: + continue + record["score"] = score + out.append(record) + return out + + +def _resolver_rows(result: dict[str, Any], filters: dict[str, Any]) -> list[dict[str, Any]]: + """Turn a resolver result into dataset x entity result rows (cross-venue note kept).""" + from mostlyright.registry import _catalog + + domain = result["domain"] + installed = _catalog._domain_installed(domain) + pairs_id = f"{domain}.pairs" + out: list[dict[str, Any]] = [] + for entity in result["entities"]: + record = { + "id": pairs_id, + "kind": "dataset", + "domain": domain, + "entity": entity["entity"], + "columns": [], + "units": {}, + "availability": "py", + "license": "unknown", + "pit_fidelity": "n/a", + "note": entity.get("note", ""), + "usage": _catalog.make_usage( + domain=domain, id=pairs_id, entity=entity["entity"], installed=installed + ), + "score": 0.0, + } + if _passes_filters(record, filters): + out.append(record) + return out + + +def _build_df(rows: list[dict[str, Any]]) -> pd.DataFrame: + """Assemble the shaped, deterministically-ordered result frame (score desc, id asc).""" + import pandas as pd + + from mostlyright.registry import _catalog + + ordered = sorted(rows, key=lambda r: (-r["score"], r["id"], r.get("entity", ""))) + return pd.DataFrame(ordered, columns=list(_catalog.RESULT_COLUMNS)) + + +def discover( + text: str | None = None, + *, + city: str | None = None, + **filters: Any, +) -> pd.DataFrame: + """Search the registration-metadata catalog across domains. Args: - city: city slug (``"NYC"`` for Kalshi, ``"nyc"`` for Polymarket; - both forms are normalized). + text: Optional free-text query. Known entity aliases in the query fire the + entity alias resolver (pairing dataset x entity, carrying the cross-venue + ``note``); the remaining tokens feed the deterministic scorer (R-05). + city: Backward-compatible keyword-only selector — routes to the entity alias + resolver path (identical to passing the city as a free-text alias). + **filters: Structured filters — ``domain``, ``kind`` (``source``/``label``), + ``availability`` (``py``/``ts``), ``license``, ``pit_fidelity``. Returns: - ``pd.DataFrame`` with columns: - - - ``city`` (str): the input city, echoed. - - ``station`` (str): 4-char K-prefix ICAO. - - ``settles_for`` (list[str]): ``["kalshi:NYC"]`` / ``["polymarket:nyc"]`` - / ``[]`` (denylist backstop). + ``pd.DataFrame`` with columns ``id, kind, domain, entity, columns, units, + availability, license, pit_fidelity, note, usage, score`` (``usage`` is a + language-tagged ``{py, ts}`` mapping). Deterministically ordered (score desc, + then id asc). Zero-arg ``discover()`` returns the full catalog (R-23). A + guaranteed-miss free-text query returns an empty-but-shaped frame whose + ``df.attrs["message"]`` guides the user back (R-06). Raises: - ValueError: city not in either catalog. - SourceUnavailableError: pandas not installed. + EntityNotFoundError: an explicit ``city=`` / alias could not be resolved + (carries a deterministic nearest-match suggestion). + SourceUnavailableError: pandas is not installed. """ - if not isinstance(city, str) or not city: - raise ValueError(f"city must be a non-empty str; got {city!r}") try: - import pandas as _pandas + import pandas # noqa: F401 (import-guarded; used via _build_df) except ImportError as exc: from mostlyright.core.exceptions import SourceUnavailableError @@ -53,47 +306,38 @@ def discover(*, city: str) -> pd.DataFrame: underlying=str(exc), ) from None - # Iter-1 + iter-2 codex HIGH: discover() is exported from the - # `mostlyright` (core) package but the resolver depends on - # `mostlyright.markets`, which is shipped as a separate distribution - # (`mostlyrightmd-markets`). The `_compose` module itself imports - # cleanly (the markets imports inside it are LAZY at call time), so - # wrapping only the top-level `from mostlyright._compose import ...` - # (the iter-1 fix) was not enough — the actual `ModuleNotFoundError` - # fires inside `resolve_city()` / `annotate_settles_for()` when they - # call `from mostlyright.markets... import ...`. The iter-2 fix - # wraps the resolver calls below as well, converting the lazy - # ImportError into a friendly SourceUnavailableError with the - # canonical install hint. - from mostlyright._compose import annotate_settles_for, resolve_city + from mostlyright import registry - try: - stations = resolve_city(city) - except ModuleNotFoundError as exc: - if "mostlyright.markets" in (exc.name or ""): - from mostlyright.core.exceptions import SourceUnavailableError - - raise SourceUnavailableError( - "mostlyright.discover requires the sibling `mostlyrightmd-markets` " - "distribution (for the Kalshi + Polymarket city catalogs). " - "Install with: pip install mostlyrightmd-markets", - source="discover", - retryable=False, - underlying=str(exc), - ) from None - raise - - rows: list[dict[str, Any]] = [ - { - "city": city, - "station": station, - "settles_for": annotate_settles_for(station, city), - } - for station in stations - ] - df = _pandas.DataFrame(rows, columns=["city", "station", "settles_for"]) - df.attrs["city"] = city + _ensure_resolvers() + + active_filters = {k: v for k, v in filters.items() if v is not None and v != ""} + alias_terms: list[str] = [] + text_tokens: list[str] = [] + if city: + alias_terms.append(city) + if text: + for token in _tokenize(text): + (alias_terms if _is_known_alias(token) else text_tokens).append(token) + + rows: list[dict[str, Any]] = [] + for alias in alias_terms: + rows.extend(_resolver_rows(registry.resolve_entity(alias), active_filters)) + + zero_arg = text is None and city is None and not active_filters + want_catalog = zero_arg or bool(text_tokens) or (bool(active_filters) and not alias_terms) + if want_catalog: + tokens = None if zero_arg else text_tokens + rows.extend(_catalog_rows(tokens, active_filters)) + + df = _build_df(rows) + if len(df) == 0 and text is not None: + df.attrs["message"] = ( + f"no matches for {text!r}; try discover() with no args to list all, " + f'or discover(domain="weather")' + ) df.attrs["source"] = "discover" + if city is not None: + df.attrs["city"] = city return df diff --git a/packages/core/src/mostlyright/registry/__init__.py b/packages/core/src/mostlyright/registry/__init__.py new file mode 100644 index 00000000..4c153fc0 --- /dev/null +++ b/packages/core/src/mostlyright/registry/__init__.py @@ -0,0 +1,411 @@ +"""``mr.registry`` — the demoted, author-facing catalog writer (Phase 34 02). + +``mr.registry.source(spec)`` / ``mr.registry.label(spec)`` add a validated entry to +the shipped Phase-32 ``_contributors._REGISTRY`` carrying a first-class SOURCE vs +LABEL ``kind`` axis. The registry is a PLAIN DICT STORE consulted by lookups — it +NEVER conjures an import path at runtime (static namespaces, dynamic catalog): a +malicious id can never trigger an arbitrary import, and mypy / tree-shaking resolve +every symbol statically. The registry only WRITES the ``discover()`` catalog index +and VALIDATES contracts; the import graph stays static. + +Three registration-time invariants, all enforced here (D-34.05/06/07/08, R-09): + +1. **The ``.label.`` invariant (both directions).** ``label(spec)`` requires + ``.label.`` in ``spec.id``; ``source(spec)`` rejects ``.label.`` in ``spec.id``. + This is the actual fix for "can't tell feature from target at the call site" — + feature-vs-target is a machine-checkable write-time property. + +2. **The declared-prefix firewall (SOURCE only, R-09, OPERATOR-PINNED 2026-07-14).** + ``source(spec)`` reads ``_contributors._FORBIDDEN_LABEL_PREFIXES`` and rejects a + source whose declared ``prefix`` is a forbidden LABEL prefix (e.g. ``obs_``) with + a :class:`ContractError` NAMING the labels-only firewall — WITHOUT loosening the + firewall list. The canonical ``weather.obs`` source registers under the + operator-pinned ``met_`` prefix; ``obs_*`` stays label-only forever. The firewall + is kind-scoped: a LABEL legitimately OWNS a label prefix (``cli_``) + label + columns — validating a label through the source firewall would reject its own + flagship, so the declared-prefix + label-column firewall guards SOURCES only. + The bitemporal-field + ``pit_fidelity`` validation applies to BOTH kinds. + +3. **Idempotent, conflict-checked writes.** Registering the same id with an + identical spec is a no-op; the same id with a different spec raises (mirrors + ``core.validator.register_schema``). + +CWOP is NEVER registered here, and the four parity-firewall files stay untouched. +""" + +from __future__ import annotations + +import contextlib +import warnings +from typing import TYPE_CHECKING, Any + +from mostlyright import _contributors as _contrib +from mostlyright._contributors import ContributorSpec +from mostlyright._errmsg import contract_message, entity_not_found_message +from mostlyright.contracts._source import SourceContract +from mostlyright.contracts._spine import validate_source_contract +from mostlyright.core.exceptions import ( + ContractError, + ExperimentalFeatureWarning, + NoDataError, +) + +if TYPE_CHECKING: + from collections.abc import Callable + +__all__ = [ + "EntityNotFoundError", + "entries", + "get", + "label", + "register_alias_resolver", + "resolve_entity", + "source", +] + +#: The ``.label.`` path segment the invariant is keyed on (D-34.07). +_LABEL_SEGMENT = ".label." + +#: Sidecar store: id → the registered :class:`SourceContract`. This is the +#: author-facing catalog surfaced by :func:`entries` / :func:`get` and the +#: idempotency key for conflict detection. The matching :class:`ContributorSpec` +#: (carrying ``kind``) lands in the shared ``_contributors._REGISTRY`` so the +#: composer / aligner see ONE store. +_CONTRACTS: dict[str, SourceContract] = {} + + +def _catalog_marker(*_args: object, **_kwargs: object) -> dict[str, dict[str, object]]: + """Placeholder ``contribute`` for a catalog-only registry entry. + + A ``mr.registry.source()`` / ``label()`` entry is a CATALOG record (it writes + the ``discover()`` index + validates a contract); it has no live + ``contribute()`` body in this plan. The runtime contribution wiring lands in a + later phase. Invoking this marker is a programming error. + """ + raise RuntimeError( + "registry catalog entry has no contribute() body — it is a discover() index " + "record, not a live contributor. Runtime contribution wiring lands later." + ) + + +def _reject_column_firewall(spec: SourceContract) -> None: + """Apply the SOURCE-only declared-prefix + label-column firewall (R-09). + + Rejects a source declaring a forbidden LABEL prefix (``obs_`` / ``cli_`` / …) + NAMING the labels-only firewall, and reuses the shipped ``_assert_columns_clean`` + enforcer for label-shaped columns — re-raised as a :class:`ContractError` so the + registry surface raises one error type. The firewall list is READ, never edited. + """ + if spec.prefix in _contrib._FORBIDDEN_LABEL_PREFIXES: + raise ContractError( + contract_message( + source_id=spec.id, + field="prefix", + problem=( + f"declared prefix {spec.prefix!r} is a reserved LABEL prefix — the " + f"labels-only firewall forbids a SOURCE from namespacing into the " + f"label set {_contrib._FORBIDDEN_LABEL_PREFIXES}" + ), + fix="namespace the source under a non-label prefix (e.g. 'met_'); obs_* stays label-only", + ), + field="prefix", + source=spec.id, + ) + try: + _contrib._assert_columns_clean(spec.id, spec.prefix, list(spec.columns)) + except ValueError as exc: + raise ContractError( + contract_message( + source_id=spec.id, + field="columns", + problem=str(exc), + fix="remove label-shaped columns; a source may never emit label / obs_* / cli_* columns", + ), + field="columns", + source=spec.id, + ) from exc + + +def _maybe_warn_experimental(spec: SourceContract) -> None: + """Fire the one-shot ExperimentalFeatureWarning on the first registration. + + Preserves the shipped Phase-32 latch (``_contributors._EXPERIMENTAL_WARNED``) + so the whole experimental registry surface warns exactly once per session. + """ + if not _contrib._EXPERIMENTAL_WARNED["warned"]: + warnings.warn( + "mostlyright.registry is EXPERIMENTAL and semver-exempt until 2.0 (D-24). " + "The source()/label() surface, the contract shapes, and the catalog " + "semantics may change in any minor release. Pin your mostlyright version " + "if you depend on it.", + ExperimentalFeatureWarning, + stacklevel=3, + ) + _contrib._EXPERIMENTAL_WARNED["warned"] = True + + +def _write(kind: str, spec: SourceContract) -> None: + """Write a validated entry into the shared registry + the sidecar store. + + Idempotent + conflict-checked: an identical re-registration is a no-op; the same + id with a different spec (or a different kind) raises a :class:`ContractError`. + """ + existing = _CONTRACTS.get(spec.id) + if existing is not None: + prior = _contrib._REGISTRY.get(spec.id) + same_kind = prior is not None and prior.kind == kind + if existing == spec and same_kind: + return # idempotent no-op + raise ContractError( + contract_message( + source_id=spec.id, + field="id", + problem=f"id {spec.id!r} is already registered with a different contract", + fix="pick a unique id, or re-register the identical spec (idempotent)", + ), + field="id", + source=spec.id, + ) + _maybe_warn_experimental(spec) + _CONTRACTS[spec.id] = spec + _contrib._REGISTRY[spec.id] = ContributorSpec( + name=spec.id, + prefix=spec.prefix, + archive_class="refetchable", + injection_point="post_join", + fn=_catalog_marker, + columns=spec.columns, + kind=kind, + ) + + +def source(spec: SourceContract) -> SourceContract: + """Register a FEATURE source (``kind="source"``). + + Validates the bitemporal field list + ``pit_fidelity`` + prefix, applies the + SOURCE-only declared-prefix + label-column firewall (R-09), and rejects a + ``.label.`` id path. Returns the registered spec. + + Raises: + ContractError: nonconforming spec, forbidden label prefix, label-shaped + column, a ``.label.`` id path, or a conflicting re-registration. + """ + validate_source_contract(spec) + if _LABEL_SEGMENT in spec.id: + raise ContractError( + contract_message( + source_id=spec.id, + field="id", + problem=( + f"a SOURCE cannot be registered under a {_LABEL_SEGMENT!r} id path " + f"(got {spec.id!r}) — that path is reserved for labels (y targets)" + ), + fix="register a feature source under a non-label id, or use mr.registry.label() for a target", + ), + field="id", + source=spec.id, + ) + _reject_column_firewall(spec) + _write("source", spec) + return spec + + +def label(spec: SourceContract) -> SourceContract: + """Register a settlement LABEL (``kind="label"``). + + Validates the bitemporal field list + ``pit_fidelity`` + prefix, and REQUIRES a + ``.label.`` id path. The declared-prefix + label-column firewall is NOT applied + (a label legitimately owns a label prefix + label columns — that IS a target + frame). Returns the registered spec. + + Raises: + ContractError: nonconforming spec, an id path missing ``.label.``, or a + conflicting re-registration. + """ + validate_source_contract(spec) + if _LABEL_SEGMENT not in spec.id: + raise ContractError( + contract_message( + source_id=spec.id, + field="id", + problem=( + f"a LABEL must be registered under a {_LABEL_SEGMENT!r} id path " + f"(got {spec.id!r}) so feature-vs-target is unambiguous at the call site" + ), + fix=f"give the label an id containing {_LABEL_SEGMENT!r} (e.g. 'weather.label.cli')", + ), + field="id", + source=spec.id, + ) + _write("label", spec) + return spec + + +def entries() -> dict[str, SourceContract]: + """Return an isolated shallow copy of the catalog (id → SourceContract). + + Mutating the returned dict cannot corrupt the backing store — the copy IS the + isolation. No import is triggered by this lookup. + """ + return dict(_CONTRACTS) + + +def get(id: str) -> SourceContract: + """Look up a registered contract by id WITHOUT importing anything. + + Raises: + KeyError: the id is not registered. + """ + try: + return _CONTRACTS[id] + except KeyError: + raise KeyError(f"unknown registry id {id!r}; registered: {sorted(_CONTRACTS)}") from None + + +# ---------------------------------------------------------------------- +# Entity alias resolvers + deterministic nearest-match (Phase 34 05, D-34.13/14) +# ---------------------------------------------------------------------- +#: Per-domain entity alias resolvers. A resolver is a callable ``resolver(alias)`` +#: that returns a ``{"domain", "entities", "note"}`` dict (or ``None`` if it cannot +#: resolve the alias). It MAY expose ``.aliases()`` (the alias tokens ``discover()`` +#: extracts from free text) and ``.vocabulary()`` (the resolvable entity ids used for +#: the deterministic nearest-match suggestion). Generalizes the Phase-10 +#: ``_compose.resolve_city`` / ``annotate_settles_for`` cross-venue logic. +_ALIAS_RESOLVERS: dict[str, Callable[[str], dict[str, Any] | None]] = {} + + +class EntityNotFoundError(NoDataError): + """An entity alias could not be resolved by any registered resolver (R-07). + + Carries a DETERMINISTIC nearest-match ``suggestion`` computed by pure + edit-distance / prefix match over the static registry id set + the resolver + vocabularies — NO fuzzy-match / vector-similarity / network dependency + (threat T-34-19). An + identical alias always yields an identical suggestion (scriptable). + """ + + default_error_code = "ENTITY_NOT_FOUND" + + def __init__( + self, + alias: str, + suggestion: str, + *, + request_id: str | None = None, + error_code: str | None = None, + ) -> None: + super().__init__( + entity_not_found_message(alias=alias, suggestion=suggestion), + error_code=error_code, + source=alias, + request_id=request_id, + ) + self.alias: str = alias + self.suggestion: str = suggestion + + +def register_alias_resolver(domain: str, resolver: Callable[[str], dict[str, Any] | None]) -> None: + """Register a per-domain entity alias resolver (last write wins per domain).""" + _ALIAS_RESOLVERS[domain] = resolver + + +def _ensure_default_resolvers() -> None: + """Lazily trigger the built-in resolver registrations (import-time side effect). + + Importing :mod:`mostlyright.discover` registers the weather entity resolver. This + lazy hook lets :func:`resolve_entity` work standalone (before any ``discover()`` + call) without a static import cycle. + """ + if not _ALIAS_RESOLVERS: + with contextlib.suppress(Exception): # pragma: no cover - best-effort setup + # ``from mostlyright import discover`` yields the re-exported *function*, + # not the module — reach the module via ``sys.modules``. This uses a + # plain ``import`` statement (no dynamic import-by-string), so the + # static-namespace invariant (D-34.08) still holds. + import sys + + import mostlyright.discover # noqa: F401 (populate sys.modules) + + sys.modules["mostlyright.discover"]._ensure_resolvers() + + +def _edit_distance(a: str, b: str) -> int: + """Iterative edit distance, Wagner-Fischer (pure stdlib — NO fuzzy dep, T-34-19).""" + if a == b: + return 0 + if not a: + return len(b) + if not b: + return len(a) + prev = list(range(len(b) + 1)) + for i, ca in enumerate(a, start=1): + cur = [i] + for j, cb in enumerate(b, start=1): + cost = 0 if ca == cb else 1 + cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)) + prev = cur + return prev[-1] + + +def _candidate_vocabulary() -> set[str]: + """The static candidate set for nearest-match: registry ids + resolver vocab.""" + candidates: set[str] = set(_CONTRACTS) + for resolver in _ALIAS_RESOLVERS.values(): + vocab = getattr(resolver, "vocabulary", None) + if callable(vocab): + try: + candidates.update(vocab()) + except Exception: # pragma: no cover - vocab is best-effort + continue + return candidates + + +def _nearest_match(alias: str) -> str: + """Return the deterministic nearest candidate to ``alias`` (edit-distance/prefix). + + Deterministic tie-break: minimize ``(edit_distance, -prefix_len, candidate)`` so an + identical alias always yields an identical suggestion. Comparison is + case-insensitive; the candidate is returned in its registered casing. + """ + candidates = _candidate_vocabulary() + if not candidates: + return alias + target = alias.upper() + + def _key(cand: str) -> tuple[int, int, str]: + cu = cand.upper() + prefix = 0 + for x, y in zip(target, cu, strict=False): + if x != y: + break + prefix += 1 + return (_edit_distance(target, cu), -prefix, cand) + + return min(candidates, key=_key) + + +def resolve_entity(alias: str, *, domain: str | None = None) -> dict[str, Any]: + """Resolve an entity ``alias`` (e.g. ``"nyc"`` → KNYC/KLGA) via domain resolvers. + + Routes through each registered resolver (or only ``domain``'s resolver when + given), returning the first ``{"domain", "entities", "note"}`` result. The + cross-venue settlement-station warning is preserved in the ``note`` (threat + T-34-17). On an UNRESOLVABLE alias, raises :class:`EntityNotFoundError` carrying a + deterministic nearest-match suggestion (R-07, no fuzzy dep). + + Raises: + EntityNotFoundError: no resolver could resolve ``alias``. + """ + _ensure_default_resolvers() + if domain is not None and domain in _ALIAS_RESOLVERS: + items = [(domain, _ALIAS_RESOLVERS[domain])] + else: + items = list(_ALIAS_RESOLVERS.items()) + for _dom, resolver in items: + try: + result = resolver(alias) + except (KeyError, ValueError): + result = None + if result: + return result + raise EntityNotFoundError(alias, _nearest_match(alias)) diff --git a/packages/core/src/mostlyright/registry/_catalog.py b/packages/core/src/mostlyright/registry/_catalog.py new file mode 100644 index 00000000..2d68dacf --- /dev/null +++ b/packages/core/src/mostlyright/registry/_catalog.py @@ -0,0 +1,246 @@ +"""Catalog-as-static-JSON manifest store (Phase 34 05, D-34.13/14). + +The registration metadata built in Plan 02 (``registry.entries()``) IS the +``discover()`` search index. This module owns two things: + +1. **The per-entry record schema** (:func:`record_from_contract`) — the flat dict + ``discover()`` scores and renders. The load-bearing field is ``usage``, a + language-tagged ``{py, ts}`` mapping (R-20): the ``py`` form teaches the + rung-appropriate entry call (a ``pairs()`` one-liner where the domain has one, + R-14; the ``align`` expert form otherwise), and the ``ts`` form is its browser + mirror (34-07 consumes the ``ts`` side). An uninstalled-domain row carries a + ``pip install mostlyrightmd-`` hint (R-16). + +2. **The lazy static JSON manifest** (:func:`write_manifest` / :func:`load_manifest` + / :func:`manifest_path`) — written atomically (``mkstemp`` + ``os.replace``) with + ``sort_keys=True`` so the on-disk artifact is deterministic + diff-friendly + (mirrors ``weather/cwop/_registry.py``). Reads are malformed-tolerant (a tampered + or corrupt manifest yields ``[]``, never a crash or arbitrary execution — threat + T-34-16). The path honours a ``MOSTLYRIGHT_CATALOG_PATH`` env override. + +The ``usage`` string is DESCRIPTIVE TEXT — it is NEVER executed (threat T-34-15). +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import json +import os +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from mostlyright.contracts._source import SourceContract + +#: Env var overriding the manifest path (tests point it at a tmpdir). +_CATALOG_ENV = "MOSTLYRIGHT_CATALOG_PATH" + +#: Domains that expose a rung-appropriate ``pairs()`` one-liner (R-14). Every other +#: domain falls back to the ``align`` expert form. ``weather`` is the flagship: the +#: byte-equivalent ``pairs()`` surface is the "start here" call for a quant. +_PAIRS_DOMAINS: frozenset[str] = frozenset({"weather"}) + +#: Domains whose TS package ships the quickstart surface (``dataset()`` / +#: ``research()`` in the ``mostlyright`` meta package). Owned HERE — the record +#: builder — so the ``usage.ts`` snippet and the manifest ``availability`` +#: enrichment (``scripts/export_schemas.py`` imports this) share ONE source of +#: truth. ``align``/``spine``/``pairs`` are Python-only on 1.17 (PT-3401), so a +#: TS snippet must never reference them. +_TS_PORTED_DOMAINS: frozenset[str] = frozenset({"weather"}) + +#: The ordered result columns every ``discover()`` row (and manifest record) carries. +RESULT_COLUMNS: tuple[str, ...] = ( + "id", + "kind", + "domain", + "entity", + "columns", + "units", + "availability", + "license", + "pit_fidelity", + "note", + "usage", + "score", +) + +__all__ = [ + "RESULT_COLUMNS", + "load_manifest", + "make_usage", + "manifest_path", + "record_from_contract", + "write_manifest", +] + + +def _domain_installed(domain: str) -> bool: + """Whether ``mostlyright.`` is importable in this environment. + + Uses :func:`importlib.util.find_spec` (a static probe — NO import executed) so a + catalog row for a not-installed sibling distribution (e.g. a future ``sports`` + vertical) can carry a ``pip install`` hint instead of an un-runnable snippet. + """ + try: + return importlib.util.find_spec(f"mostlyright.{domain}") is not None + except (ImportError, ValueError): + return False + + +def make_usage( + *, + domain: str, + id: str, + entity: str = "", + installed: bool = True, + kind: str = "source", +) -> dict[str, str]: + """Return the language-tagged ``{py, ts}`` usage snippet for a row (R-14/16/20). + + - Uninstalled domain → the ``py`` form carries a ``pip install + mostlyrightmd-`` hint so the user knows the missing distribution + (R-16). + - Domain WITH ``pairs()`` (``weather``) → the rung-appropriate ``pairs()`` + one-liner (R-14), NOT the ``obs()`` / ``align()`` expert form. + - Otherwise → the ``align`` expert form (the only rung that exists). + + ``ts`` is TRUTHFUL (R-20, review-iter-1): ``pairs()``/``align()`` are + Python-only on 1.17 (PT-3401), so ``ts`` names the TS quickstart + (``dataset()`` from the ``mostlyright`` meta package) ONLY for a ``source`` + row in a TS-ported domain — every other row carries ``ts: ""`` and the TS + ``discover()`` falls back to the row's note/docs pointer. A TS user is + NEVER shown a snippet their SDK cannot run. + + The snippet is descriptive text; it is never executed (threat T-34-15). + """ + ent = entity or "KNYC" + ts = ( + f'await dataset("{ent}", "2024-01-01", "2024-01-31")' + if installed and kind == "source" and domain in _TS_PORTED_DOMAINS + else "" + ) + if not installed: + py = ( + f'{domain}.pairs("{ent}", "2024-01-01", "2024-01-31")' + f" # pip install mostlyrightmd-{domain}" + ) + return {"py": py, "ts": ts} + if domain in _PAIRS_DOMAINS: + py = f'{domain}.pairs("{ent}", "2024-01-01", "2024-01-31")' + return {"py": py, "ts": ts} + expert = f'align(spine, registry.get("{id}"))' + return {"py": expert, "ts": ts} + + +def record_from_contract( + id: str, + contract: SourceContract, + kind: str, + *, + installed: bool = True, + entity: str = "", + note: str = "", +) -> dict[str, Any]: + """Flatten a registered :class:`SourceContract` into a ``discover()`` record. + + ``domain`` is derived from the id's leading segment. An uninstalled-domain row + gets a ``pip install`` hint in ``note`` (unless a resolver already supplied one). + """ + domain = id.split(".", 1)[0] + if not installed and not note: + note = f"pip install mostlyrightmd-{domain}" + return { + "id": id, + "kind": kind, + "domain": domain, + "entity": entity, + "columns": list(contract.columns), + "units": dict(contract.units), + "availability": contract.availability, + "license": contract.license, + "pit_fidelity": contract.pit_fidelity, + "note": note, + "usage": make_usage(domain=domain, id=id, entity=entity, installed=installed, kind=kind), + "score": 0.0, + } + + +def _live_records() -> list[dict[str, Any]]: + """Build the manifest records from the live registry (lazy — on demand).""" + from mostlyright import _contributors as _contrib + from mostlyright.registry import entries + + out: list[dict[str, Any]] = [] + for id, contract in entries().items(): + spec = _contrib._REGISTRY.get(id) + kind = spec.kind if spec is not None else "source" + out.append( + record_from_contract( + id, + contract, + kind, + installed=_domain_installed(id.split(".", 1)[0]), + ) + ) + return out + + +def manifest_path() -> Path: + """Resolve the manifest path (honouring ``MOSTLYRIGHT_CATALOG_PATH``).""" + override = os.environ.get(_CATALOG_ENV) + if override: + return Path(override) + base = os.environ.get("MOSTLYRIGHT_CACHE_DIR") + root = Path(base) if base else Path.home() / ".mostlyright" / "cache" + return root / "catalog" / "manifest.json" + + +def write_manifest( + records: list[dict[str, Any]] | None = None, + path: str | os.PathLike[str] | None = None, +) -> Path: + """Write the catalog to a static JSON manifest atomically (``sort_keys=True``). + + Uses the ``mkstemp`` + ``os.replace`` pattern (from ``cwop/_registry.py``) so a + concurrent reader never sees a half-written file, and ``sort_keys=True`` so the + on-disk bytes are deterministic (diff-friendly). ``records`` defaults to the live + registry contents (lazy build). + """ + if records is None: + records = _live_records() + target = Path(path) if path is not None else manifest_path() + target.parent.mkdir(parents=True, exist_ok=True) + doc = {"version": 1, "entries": records} + fd, tmp = tempfile.mkstemp(dir=str(target.parent), suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(doc, fh, indent=2, sort_keys=True) + os.replace(tmp, target) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp) + raise + return target + + +def load_manifest(path: str | os.PathLike[str] | None = None) -> list[dict[str, Any]]: + """Read the manifest records, tolerant of a missing/corrupt/tampered file. + + A malformed or tampered manifest yields ``[]`` (fail-safe), never a crash or any + execution (threat T-34-16 — mirrors ``cwop/_registry._read`` + ``_provenance``). + """ + target = Path(path) if path is not None else manifest_path() + if not target.exists(): + return [] + try: + doc = json.loads(target.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError, UnicodeDecodeError): + return [] + if not isinstance(doc, dict): + return [] + entries = doc.get("entries") + if not isinstance(entries, list): + return [] + return [e for e in entries if isinstance(e, dict)] diff --git a/packages/core/src/mostlyright/research.py b/packages/core/src/mostlyright/research.py index d8f48fcf..a625aaf6 100644 --- a/packages/core/src/mostlyright/research.py +++ b/packages/core/src/mostlyright/research.py @@ -86,17 +86,14 @@ class _LabelUnset: """Sentinel distinguishing ``label`` OMITTED from an explicit ``label=None``. - Phase 32 32-02 / D-22 (the None-default bridge). The default label is - flipping ``"cli"`` → ``None`` at 2.0. During the bridge: - - - ``label`` OMITTED on ``dataset()`` still returns ``cli_*`` bytes but emits - a ``FutureWarning`` (the ``_warn_on_default_label`` shared-body flag is - ``True``). - - ``label`` OMITTED on ``research()`` returns ``cli_*`` bytes with NO new - warning forever (legacy name, dies at 2.0; ``_warn_on_default_label`` is - ``False``). - - An EXPLICIT ``label="cli"`` / ``label=None`` / ``label=`` on either - entry point is honored verbatim and never triggers the bridge warning. + Phase 32 32-02 / D-22 originally used this sentinel to drive a default-label + bridge FutureWarning (``"cli"`` → ``None`` flip at 2.0). Phase 34 06 + (D-34.22) CANCELS that flip and REMOVES the warning: an OMITTED label on + ``dataset()`` or ``research()`` resolves to ``"cli"`` byte-stable with NO + warning, forever. The sentinel is retained only to distinguish "caller passed + nothing" from "caller passed ``None``" (features-only) — a plain ``None`` + default cannot express that. An EXPLICIT ``label="cli"`` / ``label=None`` / + ``label=`` is honored verbatim. Object identity (``label is _LABEL_UNSET``) is the only way to tell "caller passed nothing" from "caller passed None" — a plain ``None`` default cannot. @@ -111,13 +108,14 @@ def __repr__(self) -> str: # pragma: no cover - debug aid #: The single shared sentinel instance (identity-compared). _LABEL_UNSET = _LabelUnset() -#: EXACT D-22 bridge copy (plan of record). Emitted once when ``dataset()`` is -#: called WITHOUT an explicit label. Asserted verbatim in the fast suite via -#: ``pytest.warns``. -_D22_BRIDGE_WARNING = ( - "dataset() label default changes from 'cli' to None in 2.0; pass " - "label='cli' to keep settlement labels or label=None to silence" -) +# Phase 34 06 (D-34.22): the D-22 bridge ``FutureWarning`` — a default that would +# FLIP from ``"cli"`` to ``None`` at 2.0 — is REMOVED. The flip is cancelled: +# an OMITTED label on ``dataset()``/``research()`` stays ``"cli"`` (byte-stable) +# with NO warning, forever. This is NOT ``pairs(..., label="cli")``, which is an +# explicit, documented, contractually-stable quickstart default (no deprecation +# path). The ``_warn_on_default_label`` sentinel is retained as an inert +# forwarding flag so the shared-body plumbing stays byte-stable, but it no longer +# gates any warning. def _resolve_station(station: str, *, allow_intl: bool = False) -> StationInfo: @@ -715,6 +713,78 @@ def _fetch_climate_range( return result +def _compose_cli_by_date( + raw_climate: list[dict[str, Any]], +) -> dict[str, dict[str, Any] | None]: + """Compose the CLI settlement label onto a settlement-day spine THROUGH + ``mr.align()`` (Phase 34 01, Path A — the falsifier). + + Instead of a bare last-wins re-key, the climate rows flow through + ``align(spine, (frame, SourceContract))``: the collapse stage reproduces the + legacy ``merge_climate`` dedup (``report_type_priority`` STRICT ``>``, + first-seen) via ``revision_order``, and an equality join attaches the winning + record to each settlement day. ``raw_climate`` is ALREADY merged at fetch + time (``_fetch_climate_range`` → ``merge_climate``), so the collapse is + idempotent and the output is byte-identical to the pre-change re-key — but it + now PROVES the bitemporal source contract expresses the dedup with no special + case inside ``align()``. + + Byte-parity: the winning row is recovered from the ORIGINAL ``raw_climate`` + dict BY IDENTITY (via a positional ``_row_ix``), so no DataFrame dtype + roundtrip ever touches the ``cli_*`` values downstream. + + PARITY FIREWALL: this only re-routes the CLI *label* attachment; it never + touches the observation-merge path, ``SOURCE_PRIORITY``, live sources, the + ``schema.observation.v1`` enum, or the frozen ``merge_climate`` itself. + """ + if not raw_climate: + return {} + + import pandas as pd + + from mostlyright.align import align + from mostlyright.contracts import SourceContract + + # Single-station call: the legacy dedup is date-only (station-agnostic within + # one call). Key the align spine on the CLIMATE ROWS' own station_code so the + # equality join matches regardless of ICAO K-stripping (info.code "NYC" vs a + # row's "KNYC") — production stamps station_code=info.code, but synthetic + # fixtures carry the raw ICAO. + station_code = raw_climate[0].get("station_code") + + frame = pd.DataFrame( + { + "station_code": [r.get("station_code") for r in raw_climate], + "observation_date": [r.get("observation_date") for r in raw_climate], + "report_type_priority": [r.get("report_type_priority", 0.0) for r in raw_climate], + "_row_ix": list(range(len(raw_climate))), + } + ) + dates = sorted({r.get("observation_date") for r in raw_climate if r.get("observation_date")}) + spine = pd.DataFrame({"station_code": station_code, "observation_date": dates}) + + spec = SourceContract( + id="weather.climate.cli", + prefix="cli_", + entity_key=("station_code",), + event_time_col="observation_date", + knowledge_time_col="observation_date", + revision_order=(("report_type_priority", "desc"),), + revision_tie="first_seen", + pit_fidelity="reconstructed", + ) + joined = align(spine, (frame, spec)) + + out: dict[str, dict[str, Any] | None] = {} + for _, row in joined.iterrows(): + obs_date = row["observation_date"] + ix = row.get("_row_ix") + if obs_date is None or ix is None or pd.isna(ix): + continue + out[obs_date] = raw_climate[int(ix)] + return out + + def _all_caches_warm( info: StationInfo, from_date_iso: str, @@ -1824,14 +1894,9 @@ def _compose_panel( column="station", ) - # FIX-5 (D-22): emit the bridge FutureWarning ONCE per panel call when the - # label was OMITTED (dataset() entry, _warn_on_default_label=True). The - # per-station fan-out forwards _warn_on_default_label=False, so the warning - # fires exactly once for the whole panel — never N-per-station, and never - # zero. research() panels pass _warn_on_default_label=False (see the alias - # split) so they stay warning-free. - if label is _LABEL_UNSET and _warn_on_default_label: - warnings.warn(_D22_BRIDGE_WARNING, FutureWarning, stacklevel=3) + # Phase 34 06 (D-34.22): the D-22 panel FutureWarning is REMOVED (the 2.0 + # label-default flip is cancelled). An OMITTED label stays "cli" byte-stable + # with no warning; ``_warn_on_default_label`` no longer gates anything. frames: list[Any] = [] nested_attrs: dict[str, dict[str, Any]] = {} @@ -2134,16 +2199,13 @@ def _dataset_impl( return_type=return_type, ) - # Phase 32 32-02 / D-22: resolve the LABEL axis UP-FRONT (before any network - # fetch). ``label`` is one of: a named recipe ("cli"/"daily_extremes"), None - # (features-only), a user DataFrame (BYO aligner), or the OMITTED sentinel - # ``_LABEL_UNSET``. The bridge: - # - OMITTED → behaves as label="cli" (byte-identical cli_* bytes). When - # ``_warn_on_default_label`` (dataset() entry, label not passed) → emit - # the exact D-22 FutureWarning ONCE. research() sets the flag False, so - # its implicit-cli path NEVER warns. - # - EXPLICIT value (incl. "cli"/None/frame) → honored verbatim, no bridge - # warning (the parity tests pin label="cli" and stay warning-free). + # Resolve the LABEL axis UP-FRONT (before any network fetch). ``label`` is one + # of: a named recipe ("cli"/"daily_extremes"), None (features-only), a user + # DataFrame (BYO aligner), or the OMITTED sentinel ``_LABEL_UNSET``. + # - OMITTED → behaves as label="cli" (byte-identical cli_* bytes) with NO + # warning. Phase 34 06 (D-34.22) cancelled the D-22 default-flip; there is + # no bridge FutureWarning on any path anymore. + # - EXPLICIT value (incl. "cli"/None/frame) → honored verbatim. from mostlyright._labels import ( NAMED_RECIPES, align_byo_labels, @@ -2153,8 +2215,8 @@ def _dataset_impl( _label_omitted = label is _LABEL_UNSET if _label_omitted: - if _warn_on_default_label: - warnings.warn(_D22_BRIDGE_WARNING, FutureWarning, stacklevel=3) + # Phase 34 06 (D-34.22): D-22 flip cancelled — an OMITTED label resolves to + # "cli" byte-stable with NO FutureWarning (``_warn_on_default_label`` inert). _effective_label: str | Any | None = "cli" else: validate_label(label) @@ -2542,11 +2604,13 @@ def _dataset_impl( if bucket is not None: bucket.append(r) - climate_by_date: dict[str, dict[str, Any] | None] = {} - for r in raw_climate: - obs_date = r.get("observation_date") - if obs_date: - climate_by_date[obs_date] = r + # Phase 34 01 (Path A): compose the CLI settlement label THROUGH mr.align() + # instead of a bare last-wins re-key. The align collapse stage reproduces the + # frozen merge_climate dedup via SourceContract.revision_order, proving the + # bitemporal source contract expresses it with no special case in align(). + # raw_climate is already merged at fetch → the collapse is idempotent and the + # cli_* output is byte-identical (winning dict recovered by identity). + climate_by_date: dict[str, dict[str, Any] | None] = _compose_cli_by_date(raw_climate) # PLAN-09: when include_forecast=False, pass None for both forecast # dicts so build_pairs/build_pairs_row hit the parity-preserving paths @@ -2790,20 +2854,17 @@ def _dataset_impl( ) -# Phase 32 32-02 / D-22: THE ALIAS SPLIT. ``dataset()`` and ``research()`` are no -# longer one object (``research = dataset``) — that identity could NOT satisfy -# D-22 (they need DIVERGENT default-label warning behavior). They are now thin -# wrappers over the single shared body ``_dataset_impl`` (no duplication of the -# ~450-line pipeline). The ONLY difference is the internal -# ``_warn_on_default_label`` sentinel: -# - dataset(): label OMITTED → cli_* bytes + a one-time D-22 FutureWarning -# (``_warn_on_default_label=True``). -# - research(): label OMITTED → cli_* bytes, NO new warning FOREVER (legacy -# name, dies at 2.0; ``_warn_on_default_label=False``). -# An EXPLICIT label on EITHER entry is honored verbatim (parity tests pin -# label="cli" and stay warning-free). The wrappers carry the full explicit -# signature so ``inspect.signature`` introspection (kwarg-only guards) and IDE -# help stay intact. +# Phase 32 32-02 (retained shape) / Phase 34 06 D-34.22: ``dataset()`` and +# ``research()`` are distinct thin wrappers over the single shared body +# ``_dataset_impl`` (no duplication of the ~450-line pipeline). Phase 32 split +# them to carry DIVERGENT D-22 default-label warning behavior; Phase 34 06 +# CANCELLED the D-22 flip and REMOVED that warning, so both wrappers now behave +# identically on an OMITTED label (→ "cli" byte-stable, NO warning). They are +# kept as separate objects for signature/IDE introspection stability and because +# ``research()`` is the deprecated legacy name (root shim, removed at 2.0). The +# inert ``_warn_on_default_label`` sentinel is threaded through unchanged so the +# shared-body plumbing stays byte-stable, but it no longer gates any warning. +# An EXPLICIT label on EITHER entry is honored verbatim. def dataset( @@ -2854,11 +2915,11 @@ def dataset( column data equals the concat of the per-station single calls; the frame adds nested ``attrs["stations"]`` per-station provenance. - **D-22 bridge:** calling ``dataset()`` WITHOUT an explicit ``label`` still - returns ``cli_*`` bytes but emits a ``FutureWarning`` (the default flips to - ``None`` at 2.0). Pass ``label="cli"`` to keep settlement labels, or - ``label=None`` to silence and get the pure feature table. ``research()`` (the - legacy alias) keeps implicit ``cli`` with no warning. + **Label default (Phase 34 06, D-34.22):** calling ``dataset()`` WITHOUT an + explicit ``label`` returns ``cli_*`` bytes with NO warning — the D-22 + default-flip (``"cli"`` → ``None`` at 2.0) is CANCELLED, so the implicit + ``"cli"`` default is stable. Pass ``label=None`` for the pure feature table, + or ``label="daily_extremes"`` / a BYO frame for other targets. """ return _dataset_impl( station, @@ -2920,13 +2981,13 @@ def research( backend: str = "pandas", return_type: str = "dataframe", ) -> Any: - """Legacy alias of :func:`dataset` — implicit ``cli`` label, NO new warning. + """Legacy alias of :func:`dataset` — implicit ``cli`` label, NO warning. - ``research()`` is the v0.14.1 name kept working byte-for-byte. Unlike - ``dataset()``, calling it WITHOUT an explicit ``label`` does NOT emit the - D-22 bridge FutureWarning (this name is deprecated toward 2.0 regardless, so - a second warning would be noise). An explicit ``label=`` is honored the same - as on ``dataset()``. + ``research()`` is the v0.14.1 name kept working byte-for-byte. Its canonical + home is now :func:`mostlyright.weather.research`; the root ``mostlyright.research`` + is a deprecated shim (removed at 2.0). Calling it WITHOUT an explicit ``label`` + returns ``cli_*`` bytes with NO warning (Phase 34 06 D-34.22 cancelled the D-22 + default-flip). An explicit ``label=`` is honored the same as on ``dataset()``. """ return _dataset_impl( station, diff --git a/packages/core/src/mostlyright/spine.py b/packages/core/src/mostlyright/spine.py new file mode 100644 index 00000000..58ab54b2 --- /dev/null +++ b/packages/core/src/mostlyright/spine.py @@ -0,0 +1,155 @@ +"""``mr.spine()`` — the BYO bridge into the guarded ``align`` world (Phase 34 04). + +``spine(df, *, entity, decision_time, y, label_available_time=None)`` lifts a +user-built frame into the canonical ``SpineContract`` shape ``(station, +decision_time, label_available_time, y_*)`` with EXPLICIT column mapping. It is +the rung-2 escape hatch for "I built my own target frame — let me into the +guarded world": the researcher names which of their columns is the entity, which +is the point-in-time ``decision_time`` cutoff, and which are the ``y`` targets. + +Threat T-34-12 (PIT integrity): the mapping is NEVER guessed. A frame missing the +mapped ``decision_time`` (or ``entity`` / a ``y`` column) raises +:class:`ContractError` through the shared ``_errmsg`` builder (R-17) — a wrong +column mapping (a label leaking as ``decision_time``) can never slip through +silently. The user's ``entity`` column is renamed to the canonical ``station`` +key (the shape ``align()`` / :class:`SpineContract` consume); when the user does +not map a ``label_available_time`` it defaults to ``decision_time`` (a +conservative, non-leaking default — the label is treated as known no earlier than +the decision). + +Reuses the label-axis machinery (``align_byo_labels`` / ``_coerce_date_column``) +so a Feast-shaped ``entity_df`` whose ``decision_time`` lives in a datetime index +is handled identically to the BYO-label path — the guarded world is canonical. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from mostlyright._errmsg import contract_message + +# Phase 34 06: ``ContractError`` is imported LAZILY inside ``spine()`` rather than +# at module top — importing ``mostlyright.core.exceptions`` eagerly triggers +# ``core.__init__`` → ``core.schemas`` → the satellite covariate schema, and the +# root ``mostlyright.__init__`` eager-binds ``spine`` (the BYO-bridge verb); keeping +# core out of this module's import graph keeps a bare ``import mostlyright`` free of +# that schema (the flags-off import-cost guard). + +if TYPE_CHECKING: + import pandas as pd + +__all__ = ["spine"] + +#: The label source-identity stamped on a BYO spine (named in mismatch errors). +_SPINE_SOURCE_ID = "mr.spine" + + +def spine( + df: pd.DataFrame, + *, + entity: str, + decision_time: str, + y: str | list[str] | tuple[str, ...], + label_available_time: str | None = None, +) -> pd.DataFrame: + """Map a BYO frame into the canonical guarded spine shape. + + Args: + df: The user-built target frame. + entity: The column naming the entity (renamed to the canonical + ``station`` key). + decision_time: The column holding the per-row point-in-time cutoff. If it + is absent as a column but lives in a datetime / date-named index, the + index is lifted into a column first (reusing ``_coerce_date_column``). + y: The target column name(s). A spine with no target is meaningless. + label_available_time: Optional column naming when the label became known; + defaults to ``decision_time`` when unmapped (conservative, non-leaking). + + Returns: + A :class:`SpineContract`-valid frame ``(station, decision_time, + label_available_time, *y)``. + + Raises: + ContractError: a mapped column (``entity`` / ``decision_time`` / a ``y``) + is absent from the frame (T-34-12 — the mapping is never guessed). + """ + import pandas as pd + + from mostlyright._labels import _coerce_date_column + from mostlyright.contracts import SpineContract, validate_spine_frame + from mostlyright.core.exceptions import ContractError + + y_cols = [y] if isinstance(y, str) else list(y) + + work = df.copy() + + # If the mapped decision_time is not a column but lives in a datetime / date- + # named index, reuse the label-axis coercion (align_byo_labels's helper) to + # lift it into a 'date' column, then treat 'date' as the decision_time source. + if decision_time not in work.columns: + idx = work.index + if isinstance(idx, pd.DatetimeIndex) or idx.name == decision_time: + work, _warn = _coerce_date_column(work.rename_axis("date")) + decision_time = "date" + + # T-34-12: EXPLICIT mapping — every mapped column must exist. Never guess. + for role, col in (("entity", entity), ("decision_time", decision_time)): + if col not in work.columns: + raise ContractError( + contract_message( + source_id=_SPINE_SOURCE_ID, + field=role, + problem=f"the mapped {role} column {col!r} is not in the frame", + fix=f"pass {role}= naming a column your frame actually carries", + ), + field=role, + source=_SPINE_SOURCE_ID, + ) + for yc in y_cols: + if yc not in work.columns: + raise ContractError( + contract_message( + source_id=_SPINE_SOURCE_ID, + field="y", + problem=f"the mapped y (target) column {yc!r} is not in the frame", + fix="pass y= naming the target column(s) your frame carries", + ), + field="y", + source=_SPINE_SOURCE_ID, + ) + + # Rename the user's entity/decision_time columns to the canonical keys. + renames = {entity: "station", decision_time: "decision_time"} + if label_available_time is not None: + if label_available_time not in work.columns: + raise ContractError( + contract_message( + source_id=_SPINE_SOURCE_ID, + field="label_available_time", + problem=( + f"the mapped label_available_time column " + f"{label_available_time!r} is not in the frame" + ), + fix="pass label_available_time= naming a column your frame carries, or omit it", + ), + field="label_available_time", + source=_SPINE_SOURCE_ID, + ) + renames[label_available_time] = "label_available_time" + work = work.rename(columns=renames) + + # Coerce the cutoff to a datetime (a spine decision_time is a timestamp). + work["decision_time"] = pd.to_datetime(work["decision_time"]) + if "label_available_time" in work.columns: + work["label_available_time"] = pd.to_datetime(work["label_available_time"]) + else: + # Conservative default: the label is known no earlier than the decision. + work["label_available_time"] = work["decision_time"] + + contract = SpineContract( + y_columns=tuple(y_cols), + label_source_id=_SPINE_SOURCE_ID, + entity_key=("station",), + ) + validate_spine_frame(work, contract) + return work diff --git a/packages/core/tests/_internal/test_capabilities.py b/packages/core/tests/_internal/test_capabilities.py index 0c13628f..ec55783b 100644 --- a/packages/core/tests/_internal/test_capabilities.py +++ b/packages/core/tests/_internal/test_capabilities.py @@ -43,10 +43,15 @@ def test_load_schema_unknown_entity_raises_value_error(): load_schema("not_a_real_entity") -def test_load_schema_pairs_raises_value_error(): - # "pairs" is explicitly unsupported (specs/pairs.json doesn't exist yet) - with pytest.raises(ValueError): - load_schema("pairs") +def test_load_schema_pairs_resolves(): + # Phase 34 06 (F-1): "pairs" is now a first-class entity — specs/pairs.json + # describes the pairs() OUTPUT columns, so load_schema("pairs") resolves. + schema = load_schema("pairs") + assert isinstance(schema, dict) + assert "properties" in schema + # The v0.14.1 training-pair contract columns are present. + for col in ("date", "station", "cli_high_f", "cli_low_f", "obs_high_f", "obs_low_f"): + assert col in schema["properties"], col def test_load_schema_caches_after_first_load(): @@ -139,9 +144,10 @@ def test_method_index_entries_have_required_keys(): # --------------------------------------------------------------------------- -def test_schema_files_excludes_pairs(): - # The "pairs" entity is intentionally absent (see _capabilities.py). - assert "pairs" not in _SCHEMA_FILES +def test_schema_files_includes_pairs(): + # Phase 34 06 (F-1): the "pairs" entity is un-deferred — it maps to pairs.json. + assert "pairs" in _SCHEMA_FILES + assert _SCHEMA_FILES["pairs"] == "pairs.json" def test_schema_files_includes_core_entities(): diff --git a/packages/core/tests/test_align.py b/packages/core/tests/test_align.py new file mode 100644 index 00000000..ef568cbd --- /dev/null +++ b/packages/core/tests/test_align.py @@ -0,0 +1,236 @@ +"""Phase 34 01 — RED/GREEN tests for ``mr.align(spine, *sources)``. + +The frozen operator as-of joins each source onto the spine and enforces the +leakage guard (``source.knowledge_time <= spine.decision_time``) in exactly one +place. A "source" passed to ``align`` is a ``(frame, SourceContract)`` pair — a +pre-materialized DataFrame plus its bitemporal contract. + +These tests pin, without any policy kwarg on ``align``: + +- the ``<=`` boundary convention (backward as-of, ``allow_exact_matches``); +- per-entity panel isolation (``by=entity_key``); +- per-source leakage ATTRIBUTION (R-22): a two-source align where only one + source carries a surviving post-``decision_time`` row raises a ``LeakageError`` + that names WHICH source leaked — the clean co-aligned source is never blamed; +- error copy (R-17): the ``LeakageError`` text carries the offending source id, + the cutoff (``decision_time``), and a concrete fix — per + ``docs/error-copy-style-guide.md``. +""" + +from __future__ import annotations + +import pandas as pd +import pytest +from mostlyright.align import align +from mostlyright.contracts import SourceContract +from mostlyright.core.exceptions import LeakageError + + +def _ts(values: list[str]) -> pd.Series: + return pd.to_datetime(values, utc=True) + + +def test_asof_boundary_le_joins_exact_and_drops_future() -> None: + """A source row at ``knowledge_time == decision_time`` IS joined (the ``<=`` + boundary via ``allow_exact_matches=True``); a strictly-future + ``knowledge_time`` row is NOT the one attached (backward as-of picks the + latest ``knowledge_time <= decision_time``).""" + spec = SourceContract( + id="met", + prefix="met_", # R-09: a FEATURE source uses a source-owned prefix, not obs_ + entity_key=("station",), + event_time_col="event_time", # NOT in the spine → backward as-of path + knowledge_time_col="knowledge_time", + pit_fidelity="exact", + ) + spine = pd.DataFrame( + { + "station": ["A"], + "decision_time": _ts(["2025-01-02T00:00:00Z"]), + } + ) + source = pd.DataFrame( + { + "station": ["A", "A"], + "event_time": _ts(["2025-01-02T00:00:00Z", "2025-01-03T00:00:00Z"]), + "knowledge_time": _ts(["2025-01-02T00:00:00Z", "2025-01-03T00:00:00Z"]), + "value": [10, 99], + } + ) + + out = align(spine, (source, spec)) + + # The exact-boundary row (knowledge_time == decision_time) wins; the future + # row (99) is never attached. The value column is namespaced under met_ (R-09). + assert list(out["met_value"]) == [10] + + +def test_panel_isolation_by_entity() -> None: + """Per-entity panels join independently — a source row for entity A never + lands on entity B's spine row.""" + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + event_time_col="event_time", + knowledge_time_col="knowledge_time", + pit_fidelity="exact", + ) + spine = pd.DataFrame( + { + "station": ["A", "B"], + "decision_time": _ts(["2025-01-02T00:00:00Z", "2025-01-02T00:00:00Z"]), + } + ) + source = pd.DataFrame( + { + "station": ["A", "B"], + "event_time": _ts(["2025-01-01T00:00:00Z", "2025-01-01T00:00:00Z"]), + "knowledge_time": _ts(["2025-01-01T00:00:00Z", "2025-01-01T00:00:00Z"]), + "value": [1, 2], + } + ) + + out = align(spine, (source, spec)).sort_values("station").reset_index(drop=True) + + assert dict(zip(out["station"], out["met_value"], strict=True)) == {"A": 1, "B": 2} + + +def _leak_setup() -> tuple[pd.DataFrame, tuple, tuple]: + """Two sources equality-joined onto a spine carrying their ``event_date``; + only ``leaky_src`` carries a row whose ``knowledge_time`` is after the + spine's ``decision_time``.""" + spine = pd.DataFrame( + { + "station": ["A"], + "event_date": ["2025-01-02"], + "decision_time": _ts(["2025-01-02T00:00:00Z"]), + } + ) + clean_spec = SourceContract( + id="clean_src", + prefix="clean_", + entity_key=("station",), + event_time_col="event_date", # in the spine → materialized equality join + knowledge_time_col="clean_kt", + pit_fidelity="exact", + ) + clean_frame = pd.DataFrame( + { + "station": ["A"], + "event_date": ["2025-01-02"], + "clean_kt": _ts(["2025-01-01T00:00:00Z"]), # <= decision_time → clean + "clean_value": [5], + } + ) + leaky_spec = SourceContract( + id="leaky_src", + prefix="leaky_", + entity_key=("station",), + event_time_col="event_date", + knowledge_time_col="leaky_kt", + pit_fidelity="exact", + ) + leaky_frame = pd.DataFrame( + { + "station": ["A"], + "event_date": ["2025-01-02"], + "leaky_kt": _ts(["2025-01-05T00:00:00Z"]), # > decision_time → leak + "leaky_value": [7], + } + ) + return spine, (clean_frame, clean_spec), (leaky_frame, leaky_spec) + + +def test_per_source_leakage_names_offender() -> None: + """R-22: the raised ``LeakageError`` names the leaking source's id and does + NOT blame the clean co-aligned source.""" + spine, clean, leaky = _leak_setup() + + with pytest.raises(LeakageError) as excinfo: + align(spine, clean, leaky) + + assert excinfo.value.source == "leaky_src" + # The clean source is never blamed. + assert excinfo.value.source != "clean_src" + + +def test_leakage_error_copy_has_source_cutoff_fix() -> None: + """R-17: the ``LeakageError`` text carries the offending source id, the + cutoff (``decision_time``), and a concrete fix/next call — matching + ``docs/error-copy-style-guide.md``.""" + spine, clean, leaky = _leak_setup() + + with pytest.raises(LeakageError) as excinfo: + align(spine, clean, leaky) + + msg = str(excinfo.value) + # Offending source id. + assert "leaky_src" in msg + # Cutoff timestamp (the decision_time the leak crossed). + assert "2025-01-02" in msg + # A concrete fix / next call. + assert "knowledge_time" in msg + assert "provenance" in msg.lower() or "filter" in msg.lower() + + +def test_align_returns_plain_dataframe_stamped() -> None: + """``align`` returns a plain ``pd.DataFrame`` stamped ``attrs['source']``.""" + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + event_time_col="event_time", + knowledge_time_col="knowledge_time", + pit_fidelity="exact", + ) + spine = pd.DataFrame({"station": ["A"], "decision_time": _ts(["2025-01-02T00:00:00Z"])}) + source = pd.DataFrame( + { + "station": ["A"], + "event_time": _ts(["2025-01-01T00:00:00Z"]), + "knowledge_time": _ts(["2025-01-01T00:00:00Z"]), + "value": [1], + } + ) + + out = align(spine, (source, spec)) + + assert isinstance(out, pd.DataFrame) + assert out.attrs.get("source") == "align" + + +def test_align_missing_declared_column_raises_contract_error() -> None: + """Review-iter-1 HIGH: a contract that DECLARES ``columns`` must fail loudly + when the materialized frame is missing one — previously the promised + feature column was silently absent from the aligned output.""" + from mostlyright.core.exceptions import ContractError + + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + event_time_col="event_time", + knowledge_time_col="known_at", + pit_fidelity="exact", + columns=("temp_f",), + ) + frame = pd.DataFrame( + { + "station": ["A"], + "event_time": pd.to_datetime(["2025-01-01T00:00:00Z"], utc=True), + "known_at": pd.to_datetime(["2025-01-01T00:00:00Z"], utc=True), + # temp_f (declared) is MISSING. + } + ) + spine = pd.DataFrame( + { + "station": ["A"], + "decision_time": pd.to_datetime(["2025-01-02T00:00:00Z"], utc=True), + } + ) + + with pytest.raises(ContractError) as exc_info: + align(spine, (frame, spec)) + + assert "temp_f" in str(exc_info.value) diff --git a/packages/core/tests/test_align_collisions.py b/packages/core/tests/test_align_collisions.py new file mode 100644 index 00000000..1a381903 --- /dev/null +++ b/packages/core/tests/test_align_collisions.py @@ -0,0 +1,221 @@ +"""Phase 34 03 — RED/GREEN tests for align()'s column-collision prefix policy. + +``align`` namespaces each FEATURE source's non-key columns under its declared +``spec.prefix`` (default = the source short id: ``met_temp_f`` / ``cwop_temp_f``). +Two firewalls run (R-09 / R-24): + +* the declared prefix MUST NOT be one of 34-02's ``_FORBIDDEN_LABEL_PREFIXES`` + (``obs_``/``cli_``/...) — a FEATURE source may not masquerade as a LABEL; +* any post-prefix column collision raises a hard :class:`ContractError` naming + BOTH source ids, the colliding column, AND which source's ``prefix=`` to + change — pandas ``_x``/``_y`` suffixes NEVER appear. + +All error copy is built via the shared 34-01 ``_errmsg`` helper. +""" + +from __future__ import annotations + +import pandas as pd +import pytest +from mostlyright.align import align +from mostlyright.contracts import SourceContract +from mostlyright.core.exceptions import ContractError + + +def _ts(values: list[str]) -> pd.Series: + return pd.to_datetime(values, utc=True) + + +def _spine() -> pd.DataFrame: + return pd.DataFrame({"station": ["A"], "decision_time": _ts(["2025-01-05T00:00:00Z"])}) + + +def _feature( + source_id: str, + prefix: str, + kt_col: str, + *, + value: float = 1.0, + value_col: str = "temp_f", +) -> tuple[pd.DataFrame, SourceContract]: + """A minimal exact-fidelity FEATURE source (backward as-of path).""" + spec = SourceContract( + id=source_id, + prefix=prefix, + entity_key=("station",), + knowledge_time_col=kt_col, + pit_fidelity="exact", + ) + frame = pd.DataFrame( + { + "station": ["A"], + kt_col: _ts(["2025-01-01T00:00:00Z"]), + value_col: [value], + } + ) + return frame, spec + + +def test_collision_raises_contract_error_naming_both_ids_column_and_fix() -> None: + """Two sources that both claim the ``met_`` namespace collide on + ``met_temp_f`` → :class:`ContractError` naming BOTH ids, the colliding + column, AND which ``prefix=`` to change (R-24).""" + a = _feature("met", "met_", "kt_a", value=1.0) + b = _feature("cwop", "met_", "kt_b", value=2.0) # same prefix → collision + + with pytest.raises(ContractError) as exc: + align(_spine(), a, b) + + msg = str(exc.value) + # Both offending source ids named. + assert "met" in msg + assert "cwop" in msg + # The colliding output column named. + assert "met_temp_f" in msg + # The actionable rename instruction (which declared prefix= to change). + assert "prefix" in msg + + +def test_distinct_prefix_resolves_collision_and_no_xy_suffix() -> None: + """A per-source ``prefix=`` override resolves the collision; the joined + frame carries ``met_temp_f`` + ``cwop_temp_f`` and NEVER a ``_x``/``_y`` + pandas suffix.""" + a = _feature("met", "met_", "kt_a", value=1.0) + b = _feature("cwop", "cwop_", "kt_b", value=2.0) + + out = align(_spine(), a, b) + + assert "met_temp_f" in out.columns + assert "cwop_temp_f" in out.columns + assert not any(c.endswith("_x") or c.endswith("_y") for c in out.columns) + + +def test_default_prefix_is_source_short_id() -> None: + """A source with prefix ``met_`` yields ``met_temp_f`` (default = short id).""" + a = _feature("met", "met_", "kt_a", value=42.0) + + out = align(_spine(), a) + + assert "met_temp_f" in out.columns + assert 42.0 in list(out["met_temp_f"]) + + +def test_default_prefix_not_forbidden_and_label_prefix_source_refused() -> None: + """The default source prefix (``met_``) is NOT a forbidden LABEL prefix; a + FEATURE source that declares a LABEL-only prefix (``obs_``) is REFUSED with a + :class:`ContractError` (R-09 — the labels-only firewall is not loosened).""" + from mostlyright._contributors import _FORBIDDEN_LABEL_PREFIXES + + assert "met_" not in _FORBIDDEN_LABEL_PREFIXES + + sneaky = _feature("sneaky", "obs_", "kt_a") # obs_ is label-only + with pytest.raises(ContractError): + align(_spine(), sneaky) + + +# --------------------------------------------------------------------------- +# Review-iter-1 CRITICAL: two sources sharing the SAME temporal key column name +# must neither hide from the per-source leakage audit (pandas suffixing moved +# the second source's knowledge column out from under ``spec.knowledge_time_col``) +# nor leave ``_x``/``_y``-style suffix garbage in the output. +# --------------------------------------------------------------------------- + + +def test_shared_knowledge_time_col_as_of_future_row_never_surfaces() -> None: + """As-of path: source B shares A's ``known_at`` name and only has a + FUTURE-dated row. The backward as-of join must leave B's feature NaN (the + future value never surfaces) and the name collision must not corrupt the + output with pandas-suffixed key columns (previously ``known_at_x``/``_y`` + appeared and B escaped its own audit).""" + clean = _feature("met", "met_", "known_at", value=1.0) + leaky_frame, leaky_spec = _feature("cwop", "cwop_", "known_at", value=2.0) + leaky_frame = leaky_frame.assign(known_at=_ts(["2025-01-07T00:00:00Z"])) # > decision + + out = align(_spine(), clean, (leaky_frame, leaky_spec)) + + assert out.loc[0, "met_temp_f"] == 1.0 + assert pd.isna(out.loc[0, "cwop_temp_f"]) # future value NEVER surfaces + stray = [c for c in out.columns if "known_at" in c and c != "known_at"] + assert stray == [] + + +def test_shared_knowledge_time_col_equality_join_leak_still_raises() -> None: + """Equality path: same collision, spine carries the shared ``date`` key. + The suffixed copy previously escaped the audit entirely.""" + from mostlyright.core.exceptions import LeakageError + + spine = _spine().assign(date=["2025-01-05"]) + + def _dated(source_id: str, prefix: str, kt_iso: str, value: float): + spec = SourceContract( + id=source_id, + prefix=prefix, + entity_key=("station",), + event_time_col="date", + knowledge_time_col="known_at", + pit_fidelity="exact", + ) + frame = pd.DataFrame( + { + "station": ["A"], + "date": ["2025-01-05"], + "known_at": _ts([kt_iso]), + "temp_f": [value], + } + ) + return frame, spec + + clean = _dated("met", "met_", "2025-01-04T00:00:00Z", 1.0) + leaky = _dated("cwop", "cwop_", "2025-01-07T00:00:00Z", 2.0) + + with pytest.raises(LeakageError) as exc_info: + align(spine, clean, leaky) + + assert "cwop" in str(exc_info.value) + + +def test_shared_temporal_key_names_produce_no_pandas_suffix_columns() -> None: + """Two CLEAN sources sharing ``known_at``: both features land, and no + suffixed (``_x``/``_y``/``_``) temporal-key columns appear.""" + clean_a = _feature("met", "met_", "known_at", value=1.0) + clean_b = _feature("cwop", "cwop_", "known_at", value=2.0) + + out = align(_spine(), clean_a, clean_b) + + assert out.loc[0, "met_temp_f"] == 1.0 + assert out.loc[0, "cwop_temp_f"] == 2.0 + stray = [c for c in out.columns if "known_at" in c and c != "known_at"] + assert stray == [] + + +def test_spine_owned_internal_name_does_not_dodge_audit() -> None: + """Review-iter-2 HIGH: a spine column that happens to be named + ``__mr_kt_`` must not collide with the isolation name and let an + equality-joined source with a FUTURE knowledge_time dodge its audit.""" + from mostlyright.core.exceptions import LeakageError + + spine = _spine().assign( + date=["2025-01-05"], + __mr_kt_cwop=_ts(["2025-01-01T00:00:00Z"]), # clean-looking decoy + ) + spec = SourceContract( + id="cwop", + prefix="cwop_", + entity_key=("station",), + event_time_col="date", + knowledge_time_col="known_at", + pit_fidelity="exact", + ) + frame = pd.DataFrame( + { + "station": ["A"], + "date": ["2025-01-05"], + "known_at": _ts(["2025-01-07T00:00:00Z"]), # AFTER decision → leak + "temp_f": [2.0], + } + ) + + with pytest.raises(LeakageError) as exc_info: + align(spine, (frame, spec)) + + assert "cwop" in str(exc_info.value) diff --git a/packages/core/tests/test_align_lazy.py b/packages/core/tests/test_align_lazy.py new file mode 100644 index 00000000..0b0f9b69 --- /dev/null +++ b/packages/core/tests/test_align_lazy.py @@ -0,0 +1,289 @@ +"""Phase 34 03 — RED/GREEN tests for window-optional source builders + lazy specs. + +A source builder call WITHOUT a window returns a :class:`LazySourceSpec` (not an +eager frame, not a ``TypeError``); ``align`` infers the fetch window from the +spine's ``decision_time`` min/max and materializes the spec (R-02). An +already-materialized ``(frame, spec)`` source (BYO / rung-1) is accepted +unchanged. A ``pit_fidelity="latest_only"`` source is REFUSED by default (R-18) +with a :class:`ContractError` naming the source + the vintage risk + the +``econ.vintages()``/ALFRED pointer; ``exact``/``reconstructed`` pass silently. +""" + +from __future__ import annotations + +import pandas as pd +import pytest +from mostlyright.align import LazySourceSpec, align +from mostlyright.contracts import SourceContract +from mostlyright.core.exceptions import ContractError + + +def _ts(values: list[str]) -> pd.Series: + return pd.to_datetime(values, utc=True) + + +def test_dateless_obs_returns_lazy_spec_not_frame_not_typeerror() -> None: + """``weather.obs('KNYC')`` (no window) returns a ``LazySourceSpec`` — NOT a + DataFrame and NOT a ``TypeError`` (the pairs() desugaring + ``align(spine, weather.obs('KNYC'))`` must run verbatim).""" + from mostlyright.weather import obs + + spec = obs("KNYC") + + assert isinstance(spec, LazySourceSpec) + assert not isinstance(spec, pd.DataFrame) + assert spec.entity == "KNYC" + + +def test_dated_obs_returns_eager_frame_not_lazy(monkeypatch) -> None: + """``weather.obs('KNYC', d1, d2)`` returns the eager frame (byte-stable path), + NOT a lazy spec. Network is stubbed at the dispatch boundary.""" + import importlib + + # ``mostlyright.weather.obs`` the submodule is shadowed by the re-exported + # ``obs`` function in the package namespace — reach the module via importlib. + obs_mod = importlib.import_module("mostlyright.weather.obs") + + monkeypatch.setattr( + obs_mod, + "_dispatch_strategy", + lambda *a, **k: [{"observed_at": "2024-03-01T12:00:00Z", "temp_f": 40.0}], + ) + + out = obs_mod.obs("KNYC", "2024-03-01", "2024-03-02") + + assert isinstance(out, pd.DataFrame) + assert not isinstance(out, LazySourceSpec) + + +def test_align_materializes_lazy_spec_from_spine_decision_bounds() -> None: + """``align`` materializes a window-less source using the spine's + ``decision_time`` min and max as the fetch window; the builder receives + exactly those ISO bounds and the prefixed rows join in.""" + calls: list[tuple[str, str, str]] = [] + + def builder(entity: str, from_date: str, to_date: str) -> pd.DataFrame: + calls.append((entity, from_date, to_date)) + return pd.DataFrame( + { + "station": [entity], + "kt": _ts([from_date + "T00:00:00Z"]), + "temp_f": [7.0], + } + ) + + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + knowledge_time_col="kt", + pit_fidelity="exact", + ) + lazy = LazySourceSpec(contract=spec, entity="KNYC", builder=builder) + spine = pd.DataFrame( + { + "station": ["KNYC", "KNYC"], + "decision_time": _ts(["2025-01-03T00:00:00Z", "2025-01-10T00:00:00Z"]), + } + ) + + out = align(spine, lazy) + + assert calls == [("KNYC", "2025-01-03", "2025-01-10")] + assert "met_temp_f" in out.columns + + +def test_byo_materialized_frame_accepted() -> None: + """An already-materialized ``(frame, spec)`` source is accepted unchanged.""" + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + knowledge_time_col="kt", + pit_fidelity="exact", + ) + frame = pd.DataFrame({"station": ["A"], "kt": _ts(["2025-01-01T00:00:00Z"]), "temp_f": [3.0]}) + spine = pd.DataFrame({"station": ["A"], "decision_time": _ts(["2025-01-05T00:00:00Z"])}) + + out = align(spine, (frame, spec)) + + assert "met_temp_f" in out.columns + + +def test_latest_only_source_refused_with_econ_pointer() -> None: + """R-18: a ``pit_fidelity="latest_only"`` source is REFUSED — a + :class:`ContractError` naming the offending source, the vintage risk, and the + ``econ.vintages()``/ALFRED settlement-grade alternative.""" + spec = SourceContract( + id="fred.cpi", + prefix="cpi_", + entity_key=("station",), + knowledge_time_col="kt", + pit_fidelity="latest_only", + ) + frame = pd.DataFrame({"station": ["A"], "kt": _ts(["2025-01-01T00:00:00Z"]), "cpi": [1.0]}) + spine = pd.DataFrame({"station": ["A"], "decision_time": _ts(["2025-01-05T00:00:00Z"])}) + + with pytest.raises(ContractError) as exc: + align(spine, (frame, spec)) + + msg = str(exc.value) + assert "fred.cpi" in msg + assert "vintage" in msg.lower() + assert "econ.vintages()" in msg or "ALFRED" in msg + + +def test_exact_and_reconstructed_pass_the_pit_gate() -> None: + """``exact`` and ``reconstructed`` sources pass the pit_fidelity gate.""" + for fidelity in ("exact", "reconstructed"): + spec = SourceContract( + id="s", + prefix="s_", + entity_key=("station",), + knowledge_time_col="kt", + pit_fidelity=fidelity, # type: ignore[arg-type] + ) + frame = pd.DataFrame({"station": ["A"], "kt": _ts(["2025-01-01T00:00:00Z"]), "v": [1.0]}) + spine = pd.DataFrame({"station": ["A"], "decision_time": _ts(["2025-01-05T00:00:00Z"])}) + + out = align(spine, (frame, spec)) # must not raise + + assert isinstance(out, pd.DataFrame) + + +# --------------------------------------------------------------------------- +# Review-iter-1 CRITICAL: the lazy fetch window must come from the spine's +# settlement-day key (the source's equality-join ``event_time_col``) when the +# spine carries it. ``decision_time`` (16:30 LST market close) crosses UTC +# midnight at Pacific/Alaska/Hawaii stations, so ``decision_time.date()`` is +# ``settlement date + 1`` and a date-derived window silently drops the first +# settlement day's features (KLAX-class regression). +# --------------------------------------------------------------------------- + + +def test_lazy_window_prefers_event_time_col_over_utc_decision_date() -> None: + """A KLAX-style spine (decision_time = 00:30 UTC NEXT day) must materialize + the lazy source over the SETTLEMENT dates, not the shifted UTC dates.""" + captured: dict[str, tuple[str, str]] = {} + + def builder(entity: str, from_date: str, to_date: str) -> pd.DataFrame: + captured["window"] = (from_date, to_date) + return pd.DataFrame( + { + "station": [entity, entity], + "date": ["2025-03-01", "2025-03-02"], + "known_at": _ts(["2025-03-01T23:00:00Z", "2025-03-02T23:00:00Z"]), + "temp_f": [60.0, 61.0], + } + ) + + contract = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + event_time_col="date", + knowledge_time_col="known_at", + pit_fidelity="exact", + ) + spine = pd.DataFrame( + { + "station": ["KLAX", "KLAX"], + "date": ["2025-03-01", "2025-03-02"], + # 16:30 PST = 00:30 UTC the NEXT calendar day. + "decision_time": _ts(["2025-03-02T00:30:00Z", "2025-03-03T00:30:00Z"]), + } + ) + + out = align(spine, LazySourceSpec(contract=contract, entity="KLAX", builder=builder)) + + assert captured["window"] == ("2025-03-01", "2025-03-02") + # The FIRST settlement day keeps its feature (previously silently NaN). + assert out.loc[0, "met_temp_f"] == 60.0 + + +def test_lazy_window_falls_back_to_decision_time_without_event_key() -> None: + """A spine WITHOUT the source's event_time_col keeps the decision_time + min/max inference (the as-of path is bounded by the PIT cutoffs).""" + captured: dict[str, tuple[str, str]] = {} + + def builder(entity: str, from_date: str, to_date: str) -> pd.DataFrame: + captured["window"] = (from_date, to_date) + return pd.DataFrame( + { + "station": [entity], + "known_at": _ts(["2025-01-04T00:00:00Z"]), + "temp_f": [50.0], + } + ) + + contract = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + knowledge_time_col="known_at", + pit_fidelity="exact", + ) + spine = pd.DataFrame( + { + "station": ["A"], + "decision_time": _ts(["2025-01-05T00:00:00Z"]), + } + ) + + align(spine, LazySourceSpec(contract=contract, entity="A", builder=builder)) + + assert captured["window"] == ("2025-01-05", "2025-01-05") + + +def test_dateless_obs_list_entity_materializes_per_station_panel(monkeypatch) -> None: + """Review-iter-2 HIGH: ``weather.pairs(["KNYC", "KLAX"], ...)`` desugars to + ``align(spine, weather.obs(["KNYC", "KLAX"]))`` — the lazy obs builder must + materialize a per-station panel instead of re-entering the eager + single-station path with a list (AttributeError: 'list' has no 'strip').""" + import importlib + + obs_mod = importlib.import_module("mostlyright.weather.obs") + monkeypatch.setattr( + obs_mod, + "_dispatch_strategy", + lambda *a, **k: [ + {"observed_at": "2025-01-01T18:00:00Z", "temp_f": 40.0}, + {"observed_at": "2025-01-02T18:00:00Z", "temp_f": 41.0}, + ], + ) + + lazy = obs_mod.obs(["KNYC", "KLAX"]) + assert isinstance(lazy, LazySourceSpec) + + frame, spec = lazy.materialize("2025-01-01", "2025-01-02") + + assert spec.prefix == "met_" + # Frames carry the NORMALIZED registry codes — the same form the + # days()-built spine keys on (v0.14.1 heritage). + assert set(frame["station"]) == {"NYC", "LAX"} + + +def test_lazy_obs_features_actually_attach_to_days_spine(monkeypatch) -> None: + """Review-iter-2 CRITICAL: the days()-built spine keys station AS TYPED + ("KNYC") while the eager obs daily frame carries the normalized NWS code + ("NYC") — the lazy builder must restamp the requested id or every + ``weather.pairs()`` obs feature is silently NaN.""" + import importlib + + obs_mod = importlib.import_module("mostlyright.weather.obs") + monkeypatch.setattr( + obs_mod, + "_dispatch_strategy", + lambda *a, **k: [ + {"observed_at": "2025-01-01T18:00:00Z", "temp_f": 40.0}, + {"observed_at": "2025-01-02T18:00:00Z", "temp_f": 41.0}, + ], + ) + from mostlyright.align import align + from mostlyright.weather import days + + spine = days("KNYC", "2025-01-01", "2025-01-02") + out = align(spine, obs_mod.obs("KNYC")) + + assert "obs_high_f" in out.columns + assert out["obs_high_f"].notna().all() diff --git a/packages/core/tests/test_align_revisions.py b/packages/core/tests/test_align_revisions.py new file mode 100644 index 00000000..3043dd5f --- /dev/null +++ b/packages/core/tests/test_align_revisions.py @@ -0,0 +1,181 @@ +"""Phase 34 01 — the FALSIFIER: can the bitemporal source contract express the +legacy climate dedup (``report_type_priority`` STRICT ``>``, first-seen-wins) +via a ``revision_order`` field, WITHOUT a special case inside ``align()``? + +``align``'s revision-collapse stage (``_collapse_revisions``) consumes +``SourceContract.revision_order=(("report_type_priority","desc"),)`` + +``revision_tie="first_seen"`` and MUST reproduce ``merge_climate`` byte-for-byte: + +- authority (priority) is ORTHOGONAL to arrival time — a ``correction`` (2.0) + that arrives AFTER a ``final`` (3.0) still loses to the ``final``; +- equal-priority rows resolve first-seen-wins (STRICT ``>``, not ``>=``). + +If the collapse cannot reproduce ``merge_climate``, the contract is too narrow +and the whole two-axis design must change. +""" + +from __future__ import annotations + +import pandas as pd +from mostlyright._internal.merge import merge_climate +from mostlyright.align import _collapse_revisions +from mostlyright.contracts import SourceContract + +_CLI_SPEC = SourceContract( + id="weather.climate.cli", + prefix="cli_", + entity_key=("station_code",), + event_time_col="observation_date", + knowledge_time_col="observation_date", + revision_order=(("report_type_priority", "desc"),), + revision_tie="first_seen", + pit_fidelity="reconstructed", +) + + +def _collapsed_map(frame: pd.DataFrame) -> dict: + out = _collapse_revisions(frame, _CLI_SPEC) + return {(r["station_code"], r["observation_date"]): r["high_temp_f"] for _, r in out.iterrows()} + + +def test_correction_after_final_still_loses_to_final() -> None: + """A ``final`` (priority 3.0) with an EARLIER arrival beats a ``correction`` + (priority 2.0) with a LATER arrival on the same (station, date) — authority + is orthogonal to arrival time.""" + rows = [ + { + "station_code": "KNYC", + "observation_date": "2025-01-06", + "high_temp_f": 41, + "report_type": "final", + "report_type_priority": 3.0, + }, + { + "station_code": "KNYC", + "observation_date": "2025-01-06", + "high_temp_f": 99, + "report_type": "correction", + "report_type_priority": 2.0, + }, + ] + frame = pd.DataFrame(rows) + collapsed = _collapse_revisions(frame, _CLI_SPEC) + + assert len(collapsed) == 1 + assert collapsed.iloc[0]["high_temp_f"] == 41 + assert collapsed.iloc[0]["report_type"] == "final" + + +def test_equal_priority_resolves_first_seen_strict() -> None: + """Two equal-priority rows on the same key resolve first-seen-wins (STRICT + ``>``): the SECOND equal row never overwrites the first.""" + rows = [ + { + "station_code": "KNYC", + "observation_date": "2025-01-06", + "high_temp_f": 41, + "report_type": "final", + "report_type_priority": 3.0, + }, + { + "station_code": "KNYC", + "observation_date": "2025-01-06", + "high_temp_f": 55, + "report_type": "final", + "report_type_priority": 3.0, + }, + ] + frame = pd.DataFrame(rows) + collapsed = _collapse_revisions(frame, _CLI_SPEC) + + assert len(collapsed) == 1 + assert collapsed.iloc[0]["high_temp_f"] == 41 # first-seen wins + + +def test_collapse_matches_merge_climate_byte_for_byte() -> None: + """The collapse output equals ``merge_climate`` on a shared multi-key / + multi-revision fixture (the byte-level falsifier backstop).""" + rows = [ + # key 1: final beats a later correction + { + "station_code": "KNYC", + "observation_date": "2025-01-06", + "high_temp_f": 41, + "report_type": "final", + "report_type_priority": 3.0, + }, + { + "station_code": "KNYC", + "observation_date": "2025-01-06", + "high_temp_f": 99, + "report_type": "correction", + "report_type_priority": 2.0, + }, + # key 2: preliminary then a later final — final (higher authority) wins + { + "station_code": "KNYC", + "observation_date": "2025-01-07", + "high_temp_f": 30, + "report_type": "preliminary", + "report_type_priority": 1.0, + }, + { + "station_code": "KNYC", + "observation_date": "2025-01-07", + "high_temp_f": 33, + "report_type": "final", + "report_type_priority": 3.0, + }, + # key 3: single row + { + "station_code": "KMDW", + "observation_date": "2025-01-06", + "high_temp_f": 20, + "report_type": "final", + "report_type_priority": 3.0, + }, + # key 4: two equal-priority rows — first-seen wins + { + "station_code": "KMDW", + "observation_date": "2025-01-07", + "high_temp_f": 25, + "report_type": "final", + "report_type_priority": 3.0, + }, + { + "station_code": "KMDW", + "observation_date": "2025-01-07", + "high_temp_f": 88, + "report_type": "final", + "report_type_priority": 3.0, + }, + ] + + expected = { + (r["station_code"], r["observation_date"]): r["high_temp_f"] + for r in merge_climate([dict(row) for row in rows]) + } + actual = _collapsed_map(pd.DataFrame(rows)) + + assert actual == expected + + +def test_empty_revision_order_is_identity() -> None: + """An empty ``revision_order`` makes the collapse the identity transform.""" + spec = SourceContract( + id="noop", + prefix="x_", + entity_key=("station_code",), + event_time_col="observation_date", + knowledge_time_col="observation_date", + revision_order=(), + ) + frame = pd.DataFrame( + { + "station_code": ["A", "A"], + "observation_date": ["2025-01-06", "2025-01-06"], + "high_temp_f": [1, 2], + } + ) + out = _collapse_revisions(frame, spec) + assert len(out) == 2 diff --git a/packages/core/tests/test_contracts.py b/packages/core/tests/test_contracts.py new file mode 100644 index 00000000..d5f4f533 --- /dev/null +++ b/packages/core/tests/test_contracts.py @@ -0,0 +1,152 @@ +"""Phase 34 02 — ``mr.contracts`` demoted surface tests. + +Network-free unit tests for the author-facing contract shapes + validators: +:class:`SpineContract` (the labeled-target axis), the frame validators +(``validate_source_frame`` / ``validate_spine_frame``), the spec validator +(``validate_source_contract`` — bitemporal-field + pit_fidelity + prefix), and the +``TimePoint`` re-export (the temporal primitive the whole contract layer is seeded +from). + +RED until Plan 02 GREEN lands ``contracts/_spine.py`` + the ``mr.contracts`` +re-exports. +""" + +from __future__ import annotations + +import pytest + +pd = pytest.importorskip("pandas") + +from mostlyright.contracts import ( # noqa: E402 + SourceContract, + SpineContract, + TimePoint, + validate_source_contract, + validate_source_frame, + validate_spine_frame, +) +from mostlyright.core.exceptions import ContractError # noqa: E402 + + +# --------------------------------------------------------------------------- +# SpineContract shape. +# --------------------------------------------------------------------------- +def test_spine_contract_default_fields() -> None: + spine = SpineContract(y_columns=("y_high_f",), label_source_id="weather.label.cli") + assert spine.decision_time_col == "decision_time" + assert spine.label_available_time_col == "label_available_time" + assert spine.entity_key == ("station",) + assert spine.y_columns == ("y_high_f",) + assert spine.label_source_id == "weather.label.cli" + assert spine.label_version == 1 + + +def test_spine_contract_is_frozen() -> None: + spine = SpineContract(y_columns=("y",), label_source_id="s") + with pytest.raises(Exception): # noqa: B017 - FrozenInstanceError (dataclasses) + spine.label_version = 2 # type: ignore[misc] + + +def test_spine_contract_requires_y_columns() -> None: + # A spine with no target is meaningless — loud ContractError. + with pytest.raises(ContractError) as exc: + SpineContract(y_columns=(), label_source_id="s") + assert "y_column" in str(exc.value) + + +# --------------------------------------------------------------------------- +# SourceContract prefix validation (construction-time). +# --------------------------------------------------------------------------- +def test_source_contract_bad_prefix_rejected() -> None: + with pytest.raises(ContractError) as exc: + SourceContract(id="weather.obs", prefix="met") # missing trailing "_" + assert "prefix" in str(exc.value) + + +# --------------------------------------------------------------------------- +# validate_source_contract — bitemporal + pit_fidelity. +# --------------------------------------------------------------------------- +def _good_source() -> SourceContract: + return SourceContract( + id="weather.obs", + prefix="met_", + event_time_col="event_time", + knowledge_time_col="knowledge_time", + pit_fidelity="exact", + columns=("met_temp_f",), + ) + + +def test_validate_source_contract_accepts_well_formed() -> None: + validate_source_contract(_good_source()) # no raise + + +def test_validate_source_contract_rejects_missing_bitemporal_cols() -> None: + with pytest.raises(ContractError): + validate_source_contract( + SourceContract(id="s", prefix="met_", event_time_col="", knowledge_time_col="") + ) + + +def test_validate_source_contract_rejects_unknown_pit_fidelity() -> None: + with pytest.raises(ContractError) as exc: + validate_source_contract( + SourceContract( + id="s", + prefix="met_", + event_time_col="event_time", + knowledge_time_col="knowledge_time", + pit_fidelity="bogus", # type: ignore[arg-type] + ) + ) + assert "pit_fidelity" in str(exc.value) + + +# --------------------------------------------------------------------------- +# Frame validators — raise ContractError naming the first missing column. +# --------------------------------------------------------------------------- +def test_validate_source_frame_missing_column_raises() -> None: + contract = _good_source() + df = pd.DataFrame({"event_time": [1], "met_temp_f": [70.0]}) # no knowledge_time + with pytest.raises(ContractError) as exc: + validate_source_frame(df, contract) + assert "knowledge_time" in str(exc.value) + + +def test_validate_source_frame_accepts_conforming_frame() -> None: + contract = _good_source() + df = pd.DataFrame( + {"station": ["KNYC"], "event_time": [1], "knowledge_time": [1], "met_temp_f": [70.0]} + ) + validate_source_frame(df, contract) # no raise + + +def test_validate_spine_frame_missing_y_column_raises() -> None: + spine = SpineContract(y_columns=("y_high_f",), label_source_id="weather.label.cli") + df = pd.DataFrame( + {"station": ["KNYC"], "decision_time": [1], "label_available_time": [1]} + ) # no y_high_f + with pytest.raises(ContractError) as exc: + validate_spine_frame(df, spine) + assert "y_high_f" in str(exc.value) + + +def test_validate_spine_frame_accepts_conforming_frame() -> None: + spine = SpineContract(y_columns=("y_high_f",), label_source_id="weather.label.cli") + df = pd.DataFrame( + { + "station": ["KNYC"], + "decision_time": [1], + "label_available_time": [1], + "y_high_f": [72.0], + } + ) + validate_spine_frame(df, spine) # no raise + + +# --------------------------------------------------------------------------- +# TimePoint re-export (the temporal primitive contracts is seeded from). +# --------------------------------------------------------------------------- +def test_contracts_reexports_timepoint() -> None: + tp = TimePoint("2026-01-06T12:00:00+00:00") + assert tp.to_utc().year == 2026 diff --git a/packages/core/tests/test_dataset_alias.py b/packages/core/tests/test_dataset_alias.py index c18ecfbe..a7c21f5d 100644 --- a/packages/core/tests/test_dataset_alias.py +++ b/packages/core/tests/test_dataset_alias.py @@ -9,9 +9,11 @@ byte-equivalent by construction (identical forwarded kwargs modulo the internal warn-on-default-label sentinel). See ``tests/test_parity.py:: test_dataset_is_research_alias`` for the byte-equivalence lock. -- Both names are exported from ``mostlyright.__all__``. -- ``research()`` emits NO ``DeprecationWarning`` this release (the warning - lands one minor release later — D-02/D-12). +- Phase 34 06 namespace inversion (D-34.20): ``dataset`` / ``research`` are + DROPPED from ``mostlyright.__all__`` (root is machinery + domains only) but stay + IMPORTABLE as ``__getattr__`` shims through 1.x (canonical home + ``mostlyright.weather.*``; removed at 2.0). Accessing the root shim now emits a + once-per-session ``DeprecationWarning`` naming the new home. """ from __future__ import annotations @@ -40,22 +42,37 @@ def test_dataset_and_research_forward_to_one_shared_body( assert callable(research_mod._dataset_impl) -def test_both_names_exported() -> None: - assert "dataset" in mostlyright.__all__ - assert "research" in mostlyright.__all__ +def test_both_names_dropped_from_all_but_importable() -> None: + """Phase 34 06 (D-34.20): dropped from ``__all__``, still importable via shims.""" + assert "dataset" not in mostlyright.__all__ + assert "research" not in mostlyright.__all__ + # Still resolve (shims) — importability is preserved through 1.x (T-34-19). + from mostlyright import dataset, research + assert callable(dataset) + assert callable(research) -def test_research_alias_emits_no_deprecation_warning_this_release() -> None: - """D-02: ``research()`` stays warning-free. - Merely resolving / referencing the alias must not warn — the deprecation - train adds the warning one release later. +def test_root_shim_emits_deprecation_naming_new_home() -> None: + """Phase 34 06 (D-34.20): accessing the root ``research``/``dataset`` shim warns. + + The pre-inversion D-02 contract kept these warning-free; the inversion turns + them into deprecated shims that name their canonical ``mostlyright.weather.*`` + home. The core ``mostlyright.research`` MODULE path stays warning-free (only + the root-attribute shim warns). """ + mostlyright._LEGACY_SHIM_WARNED.clear() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _ = mostlyright.research + _ = mostlyright.dataset + dep = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert any("mostlyright.weather.research" in str(w.message) for w in dep) + assert any("mostlyright.weather.dataset" in str(w.message) for w in dep) + + # The core module path (used by internal code) stays warning-free. with warnings.catch_warnings(): warnings.simplefilter("error", DeprecationWarning) - # Reference + re-import the alias; neither may raise. - _ = mostlyright.research - from mostlyright import research as _research # noqa: F401 from mostlyright.research import dataset as _dataset # noqa: F401 from mostlyright.research import research as _research2 # noqa: F401 diff --git a/packages/core/tests/test_discover.py b/packages/core/tests/test_discover.py index 620e3575..30f3b757 100644 --- a/packages/core/tests/test_discover.py +++ b/packages/core/tests/test_discover.py @@ -1,97 +1,288 @@ -"""Phase 10 — tests for the discover(city=) ergonomic surface.""" +"""Phase 34 05 — tests for the generalized ``discover(text?, **filters)`` surface. + +Supersedes the Phase-10 city-only tests: ``discover`` now searches the +registration-metadata index (Plan 02 ``registry.entries()``) with deterministic +field-weighted token scoring, fires entity alias resolvers on free text, lists the +full catalog on zero args, returns a shaped empty-state frame on a guaranteed miss, +raises a ``NoDataError`` subclass with a deterministic nearest-match on an unknown +entity, and emits a rung-appropriate, ``ast.parse``-runnable, language-tagged +``{py, ts}`` ``usage`` value per row. Keyword-only ``city=`` stays backward +compatible (routes to the resolver path). +""" from __future__ import annotations +import ast +import importlib.util +import inspect + +import mostlyright import pytest -from mostlyright import discover +from mostlyright import _contributors as _contrib +from mostlyright import discover, registry +from mostlyright.contracts._source import SourceContract +from mostlyright.core.exceptions import NoDataError +from mostlyright.registry import resolve_entity -class TestDiscoverCity: - def test_nyc_returns_dataframe_with_expected_columns(self): - df = discover(city="NYC") - assert list(df.columns) == ["city", "station", "settles_for"] - assert df.attrs["city"] == "NYC" - assert df.attrs["source"] == "discover" +# ---------------------------------------------------------------------- +# Isolation: the registry (_CONTRACTS + shared _REGISTRY) is process-global. +# Snapshot + restore around every test so registered fixtures never leak. +# ---------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def _clean_registry(): + snap_contracts = dict(registry._CONTRACTS) + snap_registry = dict(_contrib._REGISTRY) + snap_resolvers = dict(getattr(registry, "_ALIAS_RESOLVERS", {})) + registry._CONTRACTS.clear() + yield + registry._CONTRACTS.clear() + registry._CONTRACTS.update(snap_contracts) + _contrib._REGISTRY.clear() + _contrib._REGISTRY.update(snap_registry) + if hasattr(registry, "_ALIAS_RESOLVERS"): + registry._ALIAS_RESOLVERS.clear() + registry._ALIAS_RESOLVERS.update(snap_resolvers) + + +def _src( + id: str, + prefix: str, + *, + columns: tuple[str, ...] = (), + availability: str = "py", + license: str = "unknown", + pit_fidelity: str = "reconstructed", + schema_id: str = "", +) -> SourceContract: + """Build + register a minimal conforming feature SourceContract. + + Note: the labels-only firewall requires every declared column to start with the + source's ``prefix`` — the fixtures below honour that. + """ + spec = SourceContract( + id=id, + prefix=prefix, + event_time_col="ts", + knowledge_time_col="kt", + columns=columns, + availability=availability, # type: ignore[arg-type] + license=license, + pit_fidelity=pit_fidelity, # type: ignore[arg-type] + schema_id=schema_id, + ) + return registry.source(spec) + + +def _label(id: str, prefix: str, *, columns: tuple[str, ...] = ()) -> SourceContract: + spec = SourceContract( + id=id, + prefix=prefix, + event_time_col="ts", + knowledge_time_col="kt", + columns=columns, + ) + return registry.label(spec) + + +def _first_call_root(tree: ast.AST) -> str | None: + """Return the root identifier of the first Call node in ``tree``.""" + for node in ast.walk(tree): + if isinstance(node, ast.Call): + func = node.func + while isinstance(func, ast.Attribute): + func = func.value + if isinstance(func, ast.Name): + return func.id + return None + + +class TestDeterministicScoring: + def test_identical_query_identical_order(self): + _src("weather.alpha.one", "m1_", columns=("m1_x",)) + _src("weather.svc2.two", "m2_", columns=("m2_alpha",)) + _src("weather.svc3.three", "m3_", columns=("m3_x",), license="alpha") + first = discover("alpha")["id"].tolist() + second = discover("alpha")["id"].tolist() + assert first == second + assert first # non-empty + + def test_four_tier_field_weighting(self): + # "alpha" lands in exactly one metadata tier per entry: + # E1 id (weight 4) > E2 tags/schema_id (3) > E3 columns (2) > E4 description (1) + # (columns must start with the source prefix — labels-only firewall.) + _src("weather.alpha.svc", "m1_", columns=("m1_x",)) + _src("weather.svc2.foo", "m2_", columns=("m2_x",), schema_id="alpha") + _src("weather.svc3.foo", "m3_", columns=("m3_alpha",)) + _src("weather.svc4.foo", "m4_", columns=("m4_x",), license="alpha corp") + order = discover("alpha")["id"].tolist() + assert order == [ + "weather.alpha.svc", + "weather.svc2.foo", + "weather.svc3.foo", + "weather.svc4.foo", + ] + + def test_no_fuzzy_match_on_misspelled_token(self): + _src("weather.temperature.svc", "m1_", columns=("m1_x",)) + df = discover("temperatur") # deliberately misspelled — must NOT fuzzy-match + assert "weather.temperature.svc" not in df["id"].tolist() + + +class TestStructuredFilters: + def test_filter_domain_and_kind(self): + _src("weather.climate.metx", "met_", columns=("met_x",)) + _src("markets.kalshi.book", "mkt_", columns=("mkt_x",)) + _label("weather.label.cli", "cli_", columns=("cli_high_f",)) + ids = discover(domain="weather", kind="source")["id"].tolist() + assert ids == ["weather.climate.metx"] + + def test_filter_availability(self): + _src("weather.a.one", "a_", availability="py") + _src("weather.b.two", "b_", availability="ts") + ids = discover(availability="ts")["id"].tolist() + assert ids == ["weather.b.two"] + + +class TestZeroArgAndEmptyState: + def test_zero_arg_lists_full_catalog_deterministically(self): + _src("weather.c.three", "c_") + _src("weather.a.one", "a_") + _src("weather.b.two", "b_") + ids = discover()["id"].tolist() + assert set(ids) == {"weather.a.one", "weather.b.two", "weather.c.three"} + # deterministic order at equal score (id asc) + assert ids == discover()["id"].tolist() - def test_nyc_includes_KNYC_and_KLGA(self): + def test_empty_state_returns_shaped_frame_with_attrs_message(self): + _src("weather.climate.metx", "met_", columns=("met_x",)) + df = discover("zzzznomatch") + assert len(df) == 0 + for col in ("id", "kind", "domain", "usage", "note", "score"): + assert col in df.columns + msg = df.attrs.get("message", "") + assert "zzzznomatch" in msg + assert "discover()" in msg or 'domain="weather"' in msg + + +class TestAliasResolver: + def test_city_kwarg_yields_cross_venue_note(self): df = discover(city="NYC") - stations = df["station"].tolist() + stations = df["entity"].tolist() assert "KNYC" in stations assert "KLGA" in stations + notes = " ".join(n for n in df["note"].tolist() if n) + assert notes + assert "KNYC" in notes and "KLGA" in notes - def test_nyc_KNYC_settles_for_kalshi(self): - df = discover(city="NYC") - knyc_row = df[df["station"] == "KNYC"].iloc[0] - assert "kalshi:NYC" in knyc_row["settles_for"] + def test_freetext_alias_extraction_carries_note(self): + # "nyc" (known alias) → resolver; "temperature" → scorer + df = discover("nyc temperature") + assert "KNYC" in df["entity"].tolist() + notes = " ".join(n for n in df["note"].tolist() if n) + assert "KNYC" in notes and "KLGA" in notes - def test_nyc_KLGA_settles_for_polymarket(self): - """The KLGA row should annotate `polymarket:nyc` even when the - input city is 'NYC' (upper) — annotate_settles_for normalizes.""" - df = discover(city="NYC") - klga_row = df[df["station"] == "KLGA"].iloc[0] - assert "polymarket:nyc" in klga_row["settles_for"] + def test_city_backcompat_attrs_envelope(self): + df = discover(city="chicago") + assert df.attrs["source"] == "discover" + assert df.attrs["city"] == "chicago" - def test_nyc_KJFK_has_empty_settles_for(self): - """KJFK is in Polymarket's NYC denylist — no issuer settles - against it, but it's surfaced so quants see the full neighborhood.""" - df = discover(city="NYC") - kjfk_row = df[df["station"] == "KJFK"].iloc[0] - assert kjfk_row["settles_for"] == [] + def test_resolve_entity_unknown_raises_nodataerror_with_nearest(self): + with pytest.raises(NoDataError) as excinfo: + resolve_entity("knc") + assert "KNYC" in str(excinfo.value) - def test_chicago_polymarket_KORD_settles_for_polymarket(self): - df = discover(city="chicago") - kord_row = df[df["station"] == "KORD"].iloc[0] - assert "polymarket:chicago" in kord_row["settles_for"] + def test_resolve_entity_no_fuzzy_dependency(self): + src = inspect.getsource(registry) + for banned in ("rapidfuzz", "levenshtein", "thefuzz", "embedding"): + assert banned not in src.lower() - def test_chicago_KMDW_cross_issuer_kalshi_polymarket_denylist(self): - """Iter-1 python-architect HIGH: KMDW is Kalshi's Chicago station - AND in Polymarket's chicago denylist. Cross-issuer alias surfaces - it correctly — kalshi:CHI annotation present (NOT empty).""" - df = discover(city="chicago") - kmdw_row = df[df["station"] == "KMDW"].iloc[0] - # The cross-issuer alias resolves "chicago" → kalshi "CHI" → KMDW. - # The denylist surfacing now correctly shows KMDW belongs to Kalshi. - assert "kalshi:CHI" in kmdw_row["settles_for"] - - def test_chicago_and_CHI_return_same_cross_issuer_table(self): - """Both slug forms produce the same full cross-issuer neighborhood - (the iter-1 architect bite-y test).""" - long_form = discover(city="chicago") - short_form = discover(city="CHI") - assert sorted(long_form["station"].tolist()) == sorted(short_form["station"].tolist()) - - def test_empty_city_raises(self): - with pytest.raises(ValueError, match="non-empty str"): - discover(city="") - - def test_unknown_city_raises(self): - with pytest.raises(ValueError, match="unknown city"): - discover(city="atlantis") - - def test_keyword_only_arg(self): - with pytest.raises(TypeError): - discover("NYC") # type: ignore[misc] - - def test_missing_markets_pkg_raises_source_unavailable(self, monkeypatch): - """Iter-2 codex HIGH: when `mostlyright.markets` is missing - (because the user installed `mostlyright` without - `mostlyrightmd-markets`), discover() must raise a friendly - SourceUnavailableError with the install hint — NOT a raw - ModuleNotFoundError. - - Simulate the missing-markets condition by intercepting the - resolve_city call with a function that raises the canonical - error shape Python emits when the markets package isn't - installed. + def test_unknown_entity_message_routes_through_errmsg_builder(self): + """34-08 audit (R-17): the unknown-entity copy is built through the shared + ``_errmsg`` helper (single construction path) and carries the concrete + ``did you mean`` suggestion — not a bare hand-formatted string at the raise. """ - from mostlyright import _compose - from mostlyright.core.exceptions import SourceUnavailableError + from mostlyright._errmsg import entity_not_found_message + + with pytest.raises(NoDataError) as excinfo: + resolve_entity("knc") + exc = excinfo.value + # The suggestion (field 2 entity + field 4 concrete fix) is surfaced... + assert "did you mean" in str(exc) + assert exc.suggestion in str(exc) + # ...and the copy is byte-identical to the shared builder (single path). + assert str(exc) == entity_not_found_message(alias=exc.alias, suggestion=exc.suggestion) - def fake_resolve_city(_city: str): - raise ModuleNotFoundError( - "No module named 'mostlyright.markets'", name="mostlyright.markets" + +class TestUsage: + def test_weather_row_usage_names_pairs(self): + _src("weather.climate.metx", "met_", columns=("met_x",)) + row = discover(domain="weather", kind="source").iloc[0] + usage = row["usage"] + assert isinstance(usage, dict) + assert "weather.pairs" in usage["py"] + + def test_every_usage_is_language_tagged_and_runnable(self): + _src("weather.climate.metx", "met_", columns=("met_x",)) + _src("markets.kalshi.book", "mkt_", columns=("mkt_x",)) + df = discover() + assert len(df) >= 2 + for _, row in df.iterrows(): + usage = row["usage"] + assert isinstance(usage, dict) + # Both language tags are always PRESENT; ``ts`` is truthful (R-20, + # review-iter-1): a non-empty snippet only when the TS SDK can run + # it (source rows in TS-ported domains), else "" so TS discover() + # falls back to the note/docs pointer — never a Python-only call. + assert usage.get("py") + assert "ts" in usage and isinstance(usage["ts"], str) + if usage["ts"]: + assert "pairs(" not in usage["ts"] + assert "align(" not in usage["ts"] + tree = ast.parse(usage["py"]) # descriptive text, never executed + root = _first_call_root(tree) + assert root is not None + installed = importlib.util.find_spec(f"mostlyright.{root}") is not None or hasattr( + mostlyright, root ) + pip_hint = "pip install" in usage["py"] or "pip install" in (row["note"] or "") + assert installed or pip_hint + + def test_uninstalled_domain_carries_pip_hint(self): + _src("sports.nba.book", "spo_", columns=("spo_x",)) + row = discover(domain="sports").iloc[0] + hint = "pip install mostlyrightmd-sports" + assert hint in row["usage"]["py"] or hint in (row["note"] or "") + + +class TestManifest: + def test_write_then_load_roundtrip_is_deterministic(self, tmp_path): + from mostlyright.registry import _catalog + + _src("weather.climate.metx", "met_", columns=("met_x",)) + path = tmp_path / "manifest.json" + _catalog.write_manifest(path=path) + raw_first = path.read_text(encoding="utf-8") + _catalog.write_manifest(path=path) # rewrite → byte-identical (sort_keys) + assert path.read_text(encoding="utf-8") == raw_first + records = _catalog.load_manifest(path=path) + ids = [r["id"] for r in records] + assert "weather.climate.metx" in ids + # usage is language-tagged {py, ts} + rec = next(r for r in records if r["id"] == "weather.climate.metx") + assert set(rec["usage"]) == {"py", "ts"} + + def test_load_tolerates_corrupt_manifest(self, tmp_path): + from mostlyright.registry import _catalog + + path = tmp_path / "manifest.json" + path.write_text("{ this is not valid json ", encoding="utf-8") + # fail-safe: a tampered/corrupt manifest yields [], never a crash (T-34-16) + assert _catalog.load_manifest(path=path) == [] + + def test_manifest_path_honours_env_override(self, tmp_path, monkeypatch): + from mostlyright.registry import _catalog - monkeypatch.setattr(_compose, "resolve_city", fake_resolve_city) - with pytest.raises(SourceUnavailableError, match="mostlyrightmd-markets"): - discover(city="NYC") + target = tmp_path / "custom" / "cat.json" + monkeypatch.setenv("MOSTLYRIGHT_CATALOG_PATH", str(target)) + assert _catalog.manifest_path() == target diff --git a/packages/core/tests/test_namespace_inversion.py b/packages/core/tests/test_namespace_inversion.py new file mode 100644 index 00000000..b8f83ae2 --- /dev/null +++ b/packages/core/tests/test_namespace_inversion.py @@ -0,0 +1,87 @@ +"""Phase 34 06 — RED/GREEN tests for the root namespace inversion + error taxonomy. + +``mr.`` reads as the product pitch: machinery + domains only. The exact +inverted ``__all__`` set is pinned here (a symbol re-appearing at root is a +regression), the three collapsed errors are asserted one import deep, and the +domain error subclasses are asserted re-parented under ``NoDataError``. +""" + +from __future__ import annotations + +import mostlyright + +# The exact inverted root surface (34-CONTEXT namespace-inversion table). +_EXPECTED_ALL = { + "align", + "spine", + "discover", + "provenance", + "LeakageError", + "ContractError", + "NoDataError", + "weather", + "markets", + "registry", + "contracts", + "__version__", +} + +# Legacy symbols that MUST NOT be advertised in __all__ (importable via shims only). +_DROPPED = {"research", "dataset", "Station", "CATALOG", "StationCatalog", "live"} + + +def test_root_all_is_machinery_plus_domains_only() -> None: + assert set(mostlyright.__all__) == _EXPECTED_ALL + + +def test_legacy_symbols_absent_from_all() -> None: + for name in _DROPPED: + assert name not in mostlyright.__all__, f"{name} must be dropped from __all__" + + +def test_machinery_symbols_resolve() -> None: + assert callable(mostlyright.align) + assert callable(mostlyright.spine) + assert callable(mostlyright.discover) + assert callable(mostlyright.provenance) + assert mostlyright.weather is not None + assert mostlyright.markets is not None + assert mostlyright.registry is not None + assert mostlyright.contracts is not None + + +# --------------------------------------------------------------------------- +# Error taxonomy collapsed one import deep at root. +# --------------------------------------------------------------------------- + + +def test_three_errors_root_importable() -> None: + from mostlyright import ContractError, LeakageError, NoDataError + + assert issubclass(LeakageError, Exception) + assert issubclass(ContractError, Exception) + assert issubclass(NoDataError, Exception) + + +def test_domain_subclasses_reparented_under_no_data_error() -> None: + from mostlyright import NoDataError + from mostlyright.core.exceptions import NoCWOPDataError, NoLiveDataError + + assert issubclass(NoLiveDataError, NoDataError) + assert issubclass(NoCWOPDataError, NoDataError) + + +def test_root_errors_are_the_canonical_classes() -> None: + from mostlyright.core.exceptions import ( + ContractError as _CE, + ) + from mostlyright.core.exceptions import ( + LeakageError as _LE, + ) + from mostlyright.core.exceptions import ( + NoDataError as _NDE, + ) + + assert mostlyright.LeakageError is _LE + assert mostlyright.ContractError is _CE + assert mostlyright.NoDataError is _NDE diff --git a/packages/core/tests/test_panels.py b/packages/core/tests/test_panels.py index 31025905..1e0dfdac 100644 --- a/packages/core/tests/test_panels.py +++ b/packages/core/tests/test_panels.py @@ -260,19 +260,20 @@ def test_panel_rejects_list_return_type() -> None: ) -# ── FIX-5 (D-22): panel default-label FutureWarning is EXACTLY once per call ── +# ── Phase 34 06 (D-34.22): the D-22 panel default-label FutureWarning is DEAD ── +# The defect was a default that would FLIP ("cli" -> None) at 2.0 behind a +# FutureWarning; that flip is cancelled, so an OMITTED label no longer warns. -def test_panel_omitted_label_warns_exactly_once() -> None: - """A 2-station panel with the label OMITTED fires ONE D-22 FutureWarning.""" +def test_panel_omitted_label_warns_zero() -> None: + """A 2-station panel with the label OMITTED fires NO D-22 FutureWarning (flip dead).""" import warnings with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") dataset(stations=["KNYC", "KLAX"], from_date=_DATES[0], to_date=_DATES[-1]) d22 = [c for c in caught if issubclass(c.category, FutureWarning) and "label" in str(c.message)] - # Exactly one — never N-per-station (2 here), never zero. - assert len(d22) == 1 + assert d22 == [] def test_panel_explicit_label_warns_zero() -> None: diff --git a/packages/core/tests/test_provenance_verb.py b/packages/core/tests/test_provenance_verb.py new file mode 100644 index 00000000..be296282 --- /dev/null +++ b/packages/core/tests/test_provenance_verb.py @@ -0,0 +1,82 @@ +"""Phase 34 03 — RED/GREEN tests for the callable ``mr.provenance(frame)`` verb. + +``mostlyright.provenance`` is now a CALLABLE lineage verb: ``provenance(frame)`` +returns a per-column DataFrame carrying the source id AND a per-column +``valid_from``/coverage edge (R-12) read from ``df.attrs`` — so an all-NaN column +prefix is explainable. ``align`` stamps those attrs. The Phase-32 ledger reads +(``history``/``record``/``row_window_hash``) stay reachable as ATTRIBUTES on the +callable, so ``mr.provenance.history(...)`` keeps resolving (back-compat). +""" + +from __future__ import annotations + +import pandas as pd +from mostlyright.align import align +from mostlyright.contracts import SourceContract + + +def _ts(values: list[str]) -> pd.Series: + return pd.to_datetime(values, utc=True) + + +def test_provenance_is_callable_returns_frame() -> None: + import mostlyright + + assert callable(mostlyright.provenance) + out = mostlyright.provenance(pd.DataFrame({"a": [1]})) + assert isinstance(out, pd.DataFrame) + + +def test_provenance_per_column_source_and_valid_from() -> None: + import mostlyright + + frame = pd.DataFrame({"met_temp_f": [1.0], "cwop_temp_f": [2.0]}) + frame.attrs["provenance"] = {"met_temp_f": "met", "cwop_temp_f": "cwop"} + frame.attrs["coverage"] = {"met_temp_f": "2020-01-01", "cwop_temp_f": "2024-03-01"} + + lineage = mostlyright.provenance(frame) + + assert {"column", "source", "valid_from"} <= set(lineage.columns) + rec = {r["column"]: (r["source"], r["valid_from"]) for _, r in lineage.iterrows()} + assert rec["met_temp_f"] == ("met", "2020-01-01") + assert rec["cwop_temp_f"] == ("cwop", "2024-03-01") + + +def test_align_stamps_provenance_and_coverage() -> None: + spec = SourceContract( + id="met", + prefix="met_", + entity_key=("station",), + knowledge_time_col="kt", + pit_fidelity="exact", + valid_from={"A": "2024-03-01"}, + ) + frame = pd.DataFrame({"station": ["A"], "kt": _ts(["2025-01-01T00:00:00Z"]), "temp_f": [9.0]}) + spine = pd.DataFrame({"station": ["A"], "decision_time": _ts(["2025-01-05T00:00:00Z"])}) + + out = align(spine, (frame, spec)) + + assert out.attrs["provenance"]["met_temp_f"] == "met" + assert out.attrs["coverage"]["met_temp_f"] == "2024-03-01" + + +def test_all_nan_column_explainable_by_coverage() -> None: + import mostlyright + + frame = pd.DataFrame({"cwop_temp_f": [float("nan")]}) + frame.attrs["provenance"] = {"cwop_temp_f": "cwop"} + frame.attrs["coverage"] = {"cwop_temp_f": "2024-03-01"} + + lineage = mostlyright.provenance(frame) + + rec = {r["column"]: r["valid_from"] for _, r in lineage.iterrows()} + # The NaN prefix is explained by the source's coverage left-edge. + assert rec["cwop_temp_f"] == "2024-03-01" + + +def test_provenance_history_backcompat() -> None: + import mostlyright + + # The Phase-32 ledger read stays reachable as an attribute on the callable. + assert callable(mostlyright.provenance.history) + assert mostlyright.provenance.history("ZZZZ_nonexistent_station") == [] diff --git a/packages/core/tests/test_registry.py b/packages/core/tests/test_registry.py new file mode 100644 index 00000000..cff3d71f --- /dev/null +++ b/packages/core/tests/test_registry.py @@ -0,0 +1,222 @@ +"""Phase 34 02 — ``mr.registry`` kind-axis + ``.label.`` invariant + firewall tests. + +Network-free unit tests for the demoted, author-facing catalog writer +``mostlyright.registry`` (``source()`` / ``label()`` / ``entries()`` / ``get()``). +The registry EXTENDS the shipped Phase-32 ``_contributors._REGISTRY`` with a +first-class SOURCE vs LABEL ``kind`` axis (it does NOT fork a new store), enforces +the two-directional ``.label.`` registration-time invariant, applies the R-09 +declared-prefix firewall to SOURCES only (a forbidden label prefix like ``obs_`` +is rejected NAMING the firewall; the canonical obs source registers under the +operator-pinned ``met_`` prefix, signed 2026-07-14), and NEVER conjures an import +path at runtime (static namespaces, dynamic catalog). + +These tests are RED until Plan 02 GREEN lands ``registry/`` + the ``kind`` field. +""" + +from __future__ import annotations + +from pathlib import Path + +import mostlyright._contributors as contrib +import pytest +from mostlyright import registry +from mostlyright.contracts import SourceContract +from mostlyright.core.exceptions import ContractError, ExperimentalFeatureWarning + + +# --------------------------------------------------------------------------- +# Isolation — snapshot/restore the shared registry + the sidecar contract store +# per test so a registration in one test never leaks into another, and reset the +# session-latched ExperimentalFeatureWarning so the warning fires deterministically +# regardless of run order (the latch is shared with the Phase-32 contributor tests). +# --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def _isolate_registry(): + saved = dict(contrib._REGISTRY) + saved_warned = dict(contrib._EXPERIMENTAL_WARNED) + saved_builtins = dict(contrib._BUILTINS_REGISTERED) + saved_contracts = dict(registry._CONTRACTS) + contrib._EXPERIMENTAL_WARNED["warned"] = False + try: + yield + finally: + contrib._REGISTRY.clear() + contrib._REGISTRY.update(saved) + contrib._EXPERIMENTAL_WARNED.clear() + contrib._EXPERIMENTAL_WARNED.update(saved_warned) + contrib._BUILTINS_REGISTERED.clear() + contrib._BUILTINS_REGISTERED.update(saved_builtins) + registry._CONTRACTS.clear() + registry._CONTRACTS.update(saved_contracts) + + +def _src( + *, + id: str = "weather.obs", + prefix: str = "met_", + **kw, +) -> SourceContract: + """Build a well-formed SourceContract (bitemporal fields present).""" + columns = kw.pop("columns", (f"{prefix}temp_f",)) + pit_fidelity = kw.pop("pit_fidelity", "exact") + return SourceContract( + id=id, + prefix=prefix, + event_time_col="event_time", + knowledge_time_col="knowledge_time", + pit_fidelity=pit_fidelity, + columns=columns, + **kw, + ) + + +# --------------------------------------------------------------------------- +# (a) The SOURCE / LABEL kind axis. +# --------------------------------------------------------------------------- +def test_source_registers_with_kind_source() -> None: + registry.source(_src(id="weather.obs", prefix="met_")) + got = registry.get("weather.obs") + assert got.id == "weather.obs" + # The entry lands in the SHARED registry carrying kind="source". + assert contrib._REGISTRY["weather.obs"].kind == "source" + + +def test_label_registers_with_kind_label() -> None: + # 34-04: "weather.label.cli" is now a REAL import-time registration (the cli + # spine factory), so this kind-axis unit test uses a neutral sample id to stay + # isolated from that production entry (no cross-test registry collision). + registry.label(_src(id="demo.label.kindcheck", prefix="cli_", columns=("cli_high_f",))) + got = registry.get("demo.label.kindcheck") + assert got.id == "demo.label.kindcheck" + assert contrib._REGISTRY["demo.label.kindcheck"].kind == "label" + + +# --------------------------------------------------------------------------- +# (b) The two-directional .label. registration-time invariant (D-34.07). +# --------------------------------------------------------------------------- +def test_label_registered_outside_label_path_rejected() -> None: + with pytest.raises(ContractError) as exc: + registry.label(_src(id="weather.obs", prefix="met_")) + assert ".label." in str(exc.value) + + +def test_source_registered_inside_label_path_rejected() -> None: + with pytest.raises(ContractError) as exc: + registry.source(_src(id="weather.label.cli", prefix="met_")) + assert ".label." in str(exc.value) + + +# --------------------------------------------------------------------------- +# (c) The R-09 declared-prefix firewall — SOURCE only, never loosened. +# --------------------------------------------------------------------------- +def test_source_declaring_forbidden_obs_prefix_rejected_naming_firewall() -> None: + with pytest.raises(ContractError) as exc: + registry.source(_src(id="weather.obs", prefix="obs_", columns=("obs_high_f",))) + msg = str(exc.value).lower() + assert "firewall" in msg + assert "obs_" in str(exc.value) + + +def test_canonical_obs_source_under_met_prefix_succeeds() -> None: + # OPERATOR-PINNED 2026-07-14: the canonical weather.obs source registers under + # met_ with the firewall UNTOUCHED; obs_* stays label-only forever. + registry.source(_src(id="weather.obs", prefix="met_")) + assert registry.get("weather.obs").prefix == "met_" + + +def test_firewall_list_is_not_loosened() -> None: + # obs_ MUST still be a forbidden LABEL prefix after a canonical met_ registration. + registry.source(_src(id="weather.obs", prefix="met_")) + assert "obs_" in contrib._FORBIDDEN_LABEL_PREFIXES + assert contrib._FORBIDDEN_LABEL_PREFIXES == ("obs_", "cli_", "wu_", "noaa_wrh_") + + +def test_source_emitting_label_shaped_column_rejected() -> None: + # A source column that is a forbidden label column is rejected at registration. + with pytest.raises(ContractError): + registry.source(_src(id="weather.obs", prefix="met_", columns=("met_x", "cli_high_f"))) + + +# --------------------------------------------------------------------------- +# (d) Bitemporal-field + pit_fidelity spec validation. +# --------------------------------------------------------------------------- +def test_source_missing_event_time_rejected() -> None: + with pytest.raises(ContractError) as exc: + registry.source( + SourceContract( + id="weather.obs", + prefix="met_", + event_time_col="", + knowledge_time_col="knowledge_time", + pit_fidelity="exact", + columns=("met_temp_f",), + ) + ) + assert "event_time" in str(exc.value) + + +def test_source_missing_knowledge_time_rejected() -> None: + with pytest.raises(ContractError) as exc: + registry.source( + SourceContract( + id="weather.obs", + prefix="met_", + event_time_col="event_time", + knowledge_time_col="", + pit_fidelity="exact", + columns=("met_temp_f",), + ) + ) + assert "knowledge_time" in str(exc.value) + + +def test_source_unknown_pit_fidelity_rejected() -> None: + with pytest.raises(ContractError) as exc: + registry.source(_src(id="weather.obs", prefix="met_", pit_fidelity="bogus")) # type: ignore[arg-type] + assert "pit_fidelity" in str(exc.value) + + +# --------------------------------------------------------------------------- +# (e) Idempotent / conflict-checked registration (mirrors register_schema). +# --------------------------------------------------------------------------- +def test_identical_reregistration_is_noop() -> None: + registry.source(_src(id="weather.obs", prefix="met_")) + # Same id + identical spec => no-op (no raise). + registry.source(_src(id="weather.obs", prefix="met_")) + assert registry.get("weather.obs").prefix == "met_" + + +def test_conflicting_reregistration_raises() -> None: + registry.source(_src(id="weather.obs", prefix="met_")) + with pytest.raises(ContractError): + # Same id, different spec (different prefix) => conflict. + registry.source(_src(id="weather.obs", prefix="wx_", columns=("wx_temp_f",))) + + +# --------------------------------------------------------------------------- +# (f) The one-shot ExperimentalFeatureWarning is preserved. +# --------------------------------------------------------------------------- +def test_first_registration_warns_experimental() -> None: + with pytest.warns(ExperimentalFeatureWarning): + registry.source(_src(id="thirdparty.foo", prefix="foo_")) + + +# --------------------------------------------------------------------------- +# (g) Static namespaces — the registry NEVER imports by id at runtime. +# --------------------------------------------------------------------------- +def test_registry_source_has_no_runtime_import_by_id() -> None: + text = Path(registry.__file__).read_text(encoding="utf-8") + assert "import_module" not in text + assert "__import__" not in text + + +# --------------------------------------------------------------------------- +# (h) entries() returns an isolated copy — mutating it cannot corrupt the store. +# --------------------------------------------------------------------------- +def test_entries_returns_isolated_copy() -> None: + registry.source(_src(id="weather.obs", prefix="met_")) + view = registry.entries() + assert "weather.obs" in view + view.clear() + # The backing store is untouched. + assert registry.get("weather.obs").id == "weather.obs" diff --git a/packages/core/tests/test_shims.py b/packages/core/tests/test_shims.py new file mode 100644 index 00000000..6a5cc778 --- /dev/null +++ b/packages/core/tests/test_shims.py @@ -0,0 +1,186 @@ +"""Phase 34 06 — RED/GREEN tests for the legacy-root-symbol shims. + +The namespace inversion (34-06) drops ``research``/``dataset``/``Station``/ +``CATALOG``/``StationCatalog``/``live`` from the root ``__all__`` but keeps them +IMPORTABLE through 1.x as ``__getattr__`` shims that: + +- resolve byte-stable to their new canonical home under ``mostlyright.weather.*``; +- emit a once-per-session ``DeprecationWarning`` naming the new home + the 2.0 + removal target; +- do NOT change the runtime code path (research()/dataset() parity is output + bytes, not import path). + +This module also pins the D-22 flip DEATH: calling the deprecated ``dataset()`` +shim WITHOUT an explicit label no longer emits a ``FutureWarning`` (the defect +was a default that FLIPS at 2.0 behind that warning; the flip is removed). +""" + +from __future__ import annotations + +import warnings + +import pytest + + +def _reset_shim_guard() -> None: + """Clear the once-per-session legacy-shim warning guard so it can re-fire.""" + import mostlyright + + mostlyright._LEGACY_SHIM_WARNED.clear() + + +# --------------------------------------------------------------------------- +# Importability — every legacy root symbol still resolves (T-34-19). +# --------------------------------------------------------------------------- + + +def test_all_legacy_symbols_still_import() -> None: + """``from mostlyright import research, dataset, Station, CATALOG, StationCatalog``.""" + from mostlyright import ( + CATALOG, + Station, + StationCatalog, + dataset, + research, + ) + + assert callable(research) + assert callable(dataset) + assert Station is not None + assert CATALOG is not None + assert StationCatalog is not None + + +def test_live_shim_and_canonical_home_resolve() -> None: + """``mostlyright.live`` resolves AND its canonical home ``weather.live`` resolves.""" + import mostlyright + import mostlyright.weather as w + + assert mostlyright.live is not None + # Canonical home (re-exported next to weather.stations/weather.research). + assert w.live is not None + # Same module object — the root shim delegates to the canonical home. + assert mostlyright.live is w.live + + +# --------------------------------------------------------------------------- +# Deprecation — each shim names its new home + 2.0 removal. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("symbol", "new_home"), + [ + ("research", "mostlyright.weather.research"), + ("dataset", "mostlyright.weather.dataset"), + ("Station", "mostlyright.weather.stations"), + ("CATALOG", "mostlyright.weather.stations"), + ("StationCatalog", "mostlyright.weather.stations"), + ("live", "mostlyright.weather.live"), + ], +) +def test_shim_emits_deprecation_naming_new_home(symbol: str, new_home: str) -> None: + import mostlyright + + _reset_shim_guard() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + getattr(mostlyright, symbol) + + dep = [ + w + for w in caught + if issubclass(w.category, DeprecationWarning) and new_home in str(w.message) + ] + assert len(dep) >= 1, f"{symbol}: expected a DeprecationWarning naming {new_home}" + # Removal target named (2.0 / >=2 minors). + assert "2.0" in str(dep[0].message) + + +def test_live_shim_warning_names_weather_live() -> None: + import mostlyright + + _reset_shim_guard() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _ = mostlyright.live + + dep = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert any("mostlyright.weather.live" in str(w.message) for w in dep) + + +# --------------------------------------------------------------------------- +# Byte-stability — the shim delegates to the SAME object at the new home. +# --------------------------------------------------------------------------- + + +def test_research_shim_is_same_object_as_weather_research() -> None: + import mostlyright + import mostlyright.weather as w + + assert mostlyright.research is w.research + + +def test_dataset_shim_is_same_object_as_weather_dataset() -> None: + import mostlyright + import mostlyright.weather as w + + assert mostlyright.dataset is w.dataset + + +def test_stations_shims_are_same_objects_as_weather_stations() -> None: + import mostlyright + import mostlyright.weather.stations as ws + + assert mostlyright.Station is ws.Station + assert mostlyright.CATALOG is ws.CATALOG + assert mostlyright.StationCatalog is ws.StationCatalog + + +# --------------------------------------------------------------------------- +# D-22 flip is DEAD — the deprecated dataset() shim emits NO FutureWarning. +# --------------------------------------------------------------------------- + + +def test_bare_dataset_shim_emits_no_future_warning(monkeypatch: pytest.MonkeyPatch) -> None: + """Calling ``dataset()`` bare (no label) no longer emits the D-22 FutureWarning. + + Network-free: ``_resolve_station`` is stubbed to halt before any fetch, so we + only observe the up-front label-resolution path (where the D-22 warning used + to fire). + """ + import importlib + + import mostlyright + + # Resolve the research MODULE via sys.modules (not ``mostlyright.research`` + # attribute access, which the deprecation shim intercepts and returns the + # function for — the established pattern, mirrors test_mode2_deprecation.py). + research_mod = importlib.import_module("mostlyright.research") + + class _Halt(Exception): + pass + + def _boom(*_a, **_k): + raise _Halt + + monkeypatch.setattr(research_mod, "_resolve_station", _boom) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with pytest.raises(_Halt): + mostlyright.dataset("KNYC", "2025-01-06", "2025-01-12") + + future = [w for w in caught if issubclass(w.category, FutureWarning)] + assert future == [], ( + f"D-22 FutureWarning should be gone; got {[str(w.message) for w in future]}" + ) + + +def test_d22_bridge_warning_constant_removed() -> None: + """The D-22 bridge-warning constant is gone from research.py (the flip is dead).""" + import importlib + + research_mod = importlib.import_module("mostlyright.research") + + assert not hasattr(research_mod, "_D22_BRIDGE_WARNING") diff --git a/packages/core/tests/test_spine.py b/packages/core/tests/test_spine.py new file mode 100644 index 00000000..0d8a05af --- /dev/null +++ b/packages/core/tests/test_spine.py @@ -0,0 +1,92 @@ +"""Phase 34 04 — RED/GREEN tests for the ``mr.spine()`` BYO bridge (D-34.09). + +``mr.spine(df, *, entity, decision_time, y, label_available_time=None)`` lifts a +user-built frame into the guarded ``SpineContract`` world with EXPLICIT column +mapping (T-34-12): it never guesses, and a frame missing the mapped +``decision_time`` (or ``entity`` / a ``y`` column) raises :class:`ContractError`. +The user's ``entity`` column is renamed to the canonical ``station`` key (the +shape ``align()`` / ``SpineContract`` consume); ``label_available_time`` defaults +to ``decision_time`` (a conservative, non-leaking default) when unmapped. +""" + +from __future__ import annotations + +import pandas as pd +import pytest +from mostlyright.core.exceptions import ContractError +from mostlyright.spine import spine + + +def test_spine_maps_byo_frame_to_the_canonical_guarded_shape() -> None: + """A BYO frame is mapped to ``(station, decision_time, label_available_time, + y_*)`` — the user's entity column is renamed to the canonical ``station`` key.""" + df = pd.DataFrame( + { + "asset": ["KNYC", "KNYC"], + "d": ["2025-01-01", "2025-01-02"], + "y_high": [70.0, 71.0], + } + ) + + out = spine(df, entity="asset", decision_time="d", y=["y_high"]) + + assert "station" in out.columns # canonical rename (NOT the user's "asset") + assert "asset" not in out.columns + assert "decision_time" in out.columns + assert "label_available_time" in out.columns + assert "y_high" in out.columns + assert list(out["station"]) == ["KNYC", "KNYC"] + + +def test_spine_missing_decision_time_mapping_raises_contract_error() -> None: + """T-34-12: a frame missing the mapped ``decision_time`` column raises loudly — + the mapping is never guessed.""" + df = pd.DataFrame({"asset": ["KNYC"], "y_high": [70.0]}) + + with pytest.raises(ContractError) as exc: + spine(df, entity="asset", decision_time="d", y=["y_high"]) + + assert "decision_time" in str(exc.value) + + +def test_spine_missing_entity_mapping_raises_contract_error() -> None: + """A frame missing the mapped ``entity`` column raises.""" + df = pd.DataFrame({"d": ["2025-01-01"], "y_high": [70.0]}) + + with pytest.raises(ContractError): + spine(df, entity="asset", decision_time="d", y=["y_high"]) + + +def test_spine_missing_y_column_raises_contract_error() -> None: + """A frame missing a mapped ``y`` column raises (a spine needs its target).""" + df = pd.DataFrame({"asset": ["KNYC"], "d": ["2025-01-01"]}) + + with pytest.raises(ContractError): + spine(df, entity="asset", decision_time="d", y=["y_high"]) + + +def test_spine_label_available_time_defaults_to_decision_time() -> None: + """When ``label_available_time`` is unmapped it defaults to ``decision_time`` + (a conservative, non-leaking default).""" + df = pd.DataFrame({"asset": ["A"], "d": ["2025-01-01"], "y": [1.0]}) + + out = spine(df, entity="asset", decision_time="d", y="y") + + assert (out["label_available_time"] == out["decision_time"]).all() + + +def test_spine_honors_an_explicit_label_available_time_mapping() -> None: + """An explicit ``label_available_time=`` column is mapped through.""" + df = pd.DataFrame( + { + "asset": ["A"], + "d": ["2025-01-01"], + "known_at": ["2025-01-03"], + "y": [1.0], + } + ) + + out = spine(df, entity="asset", decision_time="d", y="y", label_available_time="known_at") + + assert "label_available_time" in out.columns + assert pd.Timestamp(out["label_available_time"].iloc[0]) == pd.Timestamp("2025-01-03") diff --git a/packages/econ/pyproject.toml b/packages/econ/pyproject.toml index bf4f79a1..6ac2abc1 100644 --- a/packages/econ/pyproject.toml +++ b/packages/econ/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mostlyrightmd-econ" -version = "1.16.0" +version = "1.17.0" description = "Economic-indicator data for Python — CPI, PPI, nonfarm payrolls, unemployment, GDP, jobless claims, Fed decisions — from FRED/ALFRED, BLS, BEA, DOL, Federal Reserve. First-print vintage discipline for prediction-market settlement. Imports as `mostlyright.econ`." readme = "README.md" license = "MIT" diff --git a/packages/markets/pyproject.toml b/packages/markets/pyproject.toml index 5f471873..5e20019f 100644 --- a/packages/markets/pyproject.toml +++ b/packages/markets/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mostlyrightmd-markets" -version = "1.16.0" +version = "1.17.0" description = "Prediction-market data for Python — Kalshi NHIGH/NLOW weather-contract resolvers, Polymarket discovery + settlement, and Kalshi + Polymarket trade history. For quants, backtesting, and ML training pipelines. Imports as `mostlyright.markets`." readme = "README.md" license = "MIT" diff --git a/packages/markets/src/mostlyright/markets/kalshi.py b/packages/markets/src/mostlyright/markets/kalshi.py index 1c97034d..25720e9c 100644 --- a/packages/markets/src/mostlyright/markets/kalshi.py +++ b/packages/markets/src/mostlyright/markets/kalshi.py @@ -32,10 +32,15 @@ __all__ = [ "KalshiTickerError", + "contracts", "dataset", + "label", "parse_ticker", ] +#: The Kalshi settlement label id (registered under the ``.label.`` invariant). +_KALSHI_LABEL_ID = "markets.kalshi.label.settlement" + # --------------------------------------------------------------------------- # Ticker anatomy @@ -352,8 +357,8 @@ def _full_ticker_repr(parsed: _ParsedTicker) -> str: return f"{parsed.series}-{strike}" -def dataset( - ticker: str, +def pairs( + entity: str, from_date: str, to_date: str, *, @@ -362,15 +367,20 @@ def dataset( features: list[str] | tuple[str, ...] | None = None, **core_kwargs: Any, ) -> pd.DataFrame: - """Kalshi convenience wrapper over core ``dataset(label="cli", ...)``. + """Kalshi leakage-free training pairs over the ``label="cli"`` settlement target. THIN DELEGATOR — zero duplicated join logic. Resolves the settlement station - from ``ticker`` (via the parity-critical catalog whitelist), then forwards - the whole feature-composition + join to core - :func:`mostlyright.research.dataset` with ``label="cli"`` (Kalshi NHIGH/NLOW - settle on the NWS CLI product). ``outcome=True`` appends a single binary + from ``entity`` (the full Kalshi ticker, via the parity-critical catalog + whitelist), then forwards the whole feature-composition + join to core + ``research.dataset`` with ``label="cli"`` (Kalshi NHIGH/NLOW settle on the NWS + CLI product). TWO positional dates. ``outcome=True`` appends a single binary ``label_outcome`` column from the NET-NEW strike parser. + This is the markets analogue of ``weather.pairs()``; it desugars to the domain + spine factory + sources (``kalshi.contracts()`` / ``kalshi.label.settlement()`` + + the core observation join). The old ``kalshi.dataset()`` name is a deprecation + shim routing here — ``dataset`` is reserved for the catalog noun. + **Trades:** the core ``"trades"`` feature is still a contract-gated stub (33-01 registered it as a marker; the real ``contribute()`` wiring is deferred — a station-path ``features=["trades"]`` raises the documented @@ -382,7 +392,7 @@ def dataset( no-op) until the core trades contribute() lands. Args: - ticker: The full Kalshi market ticker (``KXHIGHNY-25MAY26-T79`` / + entity: The full Kalshi market ticker (``KXHIGHNY-25MAY26-T79`` / ``KXLOWCHI-25MAY26-B40-42``). Its series → settlement station and, when ``outcome=True``, its strike → the outcome comparison. from_date, to_date: ``YYYY-MM-DD`` window bounds (forwarded verbatim). @@ -407,7 +417,7 @@ def dataset( """ from mostlyright.research import dataset as _core_dataset - parsed = parse_ticker(ticker) + parsed = parse_ticker(entity) # Merge the trades feature into the feature list only when the caller opts # in (include_trades defaults False — see the docstring Trades note). We @@ -431,3 +441,212 @@ def dataset( return result return _append_outcome(result, parsed) + + +def dataset( + ticker: str, + from_date: str, + to_date: str, + *, + outcome: bool = False, + include_trades: bool = False, + features: list[str] | tuple[str, ...] | None = None, + **core_kwargs: Any, +) -> pd.DataFrame: + """DEPRECATED shim → :func:`pairs` (Phase 34 06). + + ``kalshi.dataset()`` is renamed to :func:`kalshi.pairs` — "dataset" is reserved + for the catalog noun (what ``discover()`` returns). This shim forwards verbatim + (ZERO behavior change) and emits a ``DeprecationWarning``; removed at 2.0. + """ + import warnings + + warnings.warn( + "markets.kalshi.dataset() is deprecated; use markets.kalshi.pairs() " + "(same signature — 'dataset' is now reserved for the catalog noun). " + "Removal target: 2.0 (>=2 minor releases out).", + DeprecationWarning, + stacklevel=2, + ) + return pairs( + ticker, + from_date, + to_date, + outcome=outcome, + include_trades=include_trades, + features=features, + **core_kwargs, + ) + + +# --------------------------------------------------------------------------- +# Phase 34 34-04 — spine constructor + .label. settlement factory +# --------------------------------------------------------------------------- +# +# A spine is the primitive; a LABEL is a spine FACTORY over the SAME domain +# constructor (``contracts()``). At trade time there is no label, so serving uses +# the bare spine ``kalshi.contracts()``; training uses ``kalshi.label.settlement()`` +# built on that same constructor — so the settlement-calendar logic is minted in +# ONE place and backtest/live cannot drift (the skew guard). + + +def _spine_days(station: str, from_date: str, to_date: str) -> pd.DataFrame: + """A bare per-LST-settlement-day spine for ``station`` (station, date, decision_time). + + Stamps the NORMALIZED registry code (``"NYC"``, the shared + :func:`mostlyright._internal._pairs.station_key` convention every spine + constructor uses) so ``align(markets.*.label.*(...), weather.obs(...))`` + equality joins actually match — an as-typed key silently NaN'd every + joined feature column (review-iter-3). ``market_close_utc`` still receives + the station as passed (both forms resolve identically). + """ + import pandas as pd + from mostlyright._internal._pairs import date_range, market_close_utc, station_key + + key = station_key(station) + rows = [ + { + "station": key, + "date": day_iso, + "decision_time": pd.Timestamp(market_close_utc(day_iso, station)), + } + for day_iso in date_range(from_date, to_date) + ] + return pd.DataFrame(rows, columns=["station", "date", "decision_time"]) + + +def _lift_recipe_y( + station: str, + from_date: str, + to_date: str, + *, + core_label: str, + y_columns: tuple[str, ...], +) -> dict[str, dict[str, Any]]: + """Byte-identical recipe y columns via the shipped oracle ``dataset(label=...)``. + + Resolves core ``dataset()`` through ``sys.modules`` (the ``research`` function + attribute shadows the submodule) so the wrapped call is the same object callers + patch. ZERO duplicated join logic — ``dataset()`` IS the byte-consistency oracle. + """ + import importlib + import sys + + import pandas as pd + + mod = sys.modules.get("mostlyright.research") + if mod is None: + mod = importlib.import_module("mostlyright.research") + composed = mod.dataset(station=station, from_date=from_date, to_date=to_date, label=core_label) + dates_iso = [pd.Timestamp(v).date().isoformat() for v in composed.index] + return { + day: {c: composed[c].iloc[i] for c in y_columns if c in composed.columns} + for i, day in enumerate(dates_iso) + } + + +def _tickers_to_stations(entity: str | list[str] | tuple[str, ...]) -> list[tuple[str, str]]: + """Resolve a ticker (or list) to ``[(ticker, settlement_station), ...]``.""" + tickers = [entity] if isinstance(entity, str) else list(entity) + return [(t, parse_ticker(t).station) for t in tickers] + + +def contracts( + entity: str | list[str] | tuple[str, ...], + from_date: str, + to_date: str, +) -> pd.DataFrame: + """The bare Kalshi spine constructor (train == trade — the skew guard). + + TWO POSITIONAL DATES (inclusive ends, R-13). ``entity`` is a full Kalshi ticker + (or a list). Each ticker resolves its settlement station via the parity-critical + catalog whitelist (``parse_ticker`` — ZERO duplicated station logic); the spine + is one row per LST settlement day with NO y columns. + + PIT entity universe (T-34-13): the caller names the contracts; the factory mints + the calendar for the settlement station(s) they existed under. PIT-correct + rolling-feature transforms are OUT of scope (computed inside sources or by users + post-``align`` at their own risk). + """ + import pandas as pd + + frames = [ + _spine_days(station, from_date, to_date) for _t, station in _tickers_to_stations(entity) + ] + panel = pd.concat(frames, ignore_index=True) + return panel[["station", *[c for c in panel.columns if c != "station"]]] + + +class _KalshiLabel: + """The ``kalshi.label.*`` namespace — ``.label.``-registered settlement factories.""" + + def settlement( + self, + entity: str | list[str] | tuple[str, ...], + from_date: str, + to_date: str, + ) -> pd.DataFrame: + """The Kalshi settlement LABEL spine (NHIGH/NLOW settle on the NWS CLI product). + + Builds on ``kalshi.contracts()`` and attaches ``cli_*`` y columns byte-identical + to the shipped cli recipe (via the ``dataset(label="cli")`` oracle — ZERO + duplicated join logic). The frame carries the label source-identity (id + + version) on ``attrs``. Mints the PIT entity universe; PIT-correct + rolling-feature transforms are OUT of scope. + """ + import pandas as pd + from mostlyright._labels import RECIPE_LABEL_COLUMNS + from mostlyright.contracts import SpineContract, validate_spine_frame + + y_columns = RECIPE_LABEL_COLUMNS["cli"] + frames: list[pd.DataFrame] = [] + for _ticker, station in _tickers_to_stations(entity): + base = _spine_days(station, from_date, to_date) + colmap = _lift_recipe_y( + station, from_date, to_date, core_label="cli", y_columns=y_columns + ) + for col in y_columns: + base[col] = [colmap.get(day, {}).get(col) for day in base["date"]] + base["label_available_time"] = base["decision_time"] + frames.append(base) + + spine_df = pd.concat(frames, ignore_index=True) + spine_df = spine_df[["station", *[c for c in spine_df.columns if c != "station"]]] + + contract = SpineContract( + y_columns=y_columns, + label_source_id=_KALSHI_LABEL_ID, + entity_key=("station",), + ) + validate_spine_frame(spine_df, contract) + spine_df.attrs["label_source_id"] = _KALSHI_LABEL_ID + spine_df.attrs["label_version"] = contract.label_version + return spine_df + + +#: The ``kalshi.label`` namespace singleton (``kalshi.label.settlement(...)``). +label = _KalshiLabel() + + +def _register_label() -> None: + """Register the Kalshi settlement label under its ``.label.`` id (import-time).""" + import contextlib + + from mostlyright import registry + from mostlyright.contracts import SourceContract + from mostlyright.core.exceptions import ContractError + + with contextlib.suppress(ContractError): + registry.label( + SourceContract( + id=_KALSHI_LABEL_ID, + prefix="cli_", + entity_key=("station",), + event_time_col="date", + knowledge_time_col="label_available_time", + pit_fidelity="reconstructed", + ) + ) + + +_register_label() diff --git a/packages/markets/src/mostlyright/markets/polymarket.py b/packages/markets/src/mostlyright/markets/polymarket.py index 9f5db267..e5d7b21b 100644 --- a/packages/markets/src/mostlyright/markets/polymarket.py +++ b/packages/markets/src/mostlyright/markets/polymarket.py @@ -59,11 +59,16 @@ "PolymarketSettlementError", "PolymarketStrikeError", "TooEarlyToSettleError", + "contracts", "dataset", + "label", "polymarket_discover", "polymarket_settle", ] +#: The Polymarket settlement label id (registered under the ``.label.`` invariant). +_POLYMARKET_LABEL_ID = "markets.polymarket.label.settlement" + #: Per-city Polymarket denylist: stations that MUST NEVER resolve a #: Polymarket event for that city. Namespace-isolated from Kalshi's @@ -960,8 +965,8 @@ def _measure_label_column(measure: str) -> str: return "daily_extremes_tmax_c" if measure == "high" else "daily_extremes_tmin_c" -def dataset( - event_id: str, +def pairs( + entity: str, from_date: str, to_date: str, *, @@ -972,14 +977,18 @@ def dataset( features: list[str] | tuple[str, ...] | None = None, **core_kwargs: Any, ) -> pd.DataFrame: - """Polymarket convenience wrapper over core ``dataset(label="daily_extremes")``. + """Polymarket leakage-free training pairs over the ``label="daily_extremes"`` target. - THIN DELEGATOR — zero duplicated join logic. Resolves the event's settlement - station from the Gamma payload, then forwards the whole feature-composition + - join to core :func:`mostlyright.research.dataset` with - ``label="daily_extremes"`` (Polymarket weather markets resolve on the - WU/NOAA-WRH daily extremes, NOT CLI — the venue-correct routing that - motivated Phase 32). + THIN DELEGATOR — zero duplicated join logic. ``entity`` is the Polymarket event + id. Resolves the event's settlement station from the Gamma payload, then forwards + the whole feature-composition + join to core ``research.dataset`` with + ``label="daily_extremes"`` (Polymarket weather markets resolve on the WU/NOAA-WRH + daily extremes, NOT CLI — the venue-correct routing that motivated Phase 32). TWO + positional dates. + + This is the markets analogue of ``weather.pairs()``; the old + ``polymarket.dataset()`` name is a deprecation shim routing here — ``dataset`` is + reserved for the catalog noun. **``outcome=True`` is NETWORK-TOUCHING.** It runs a per-settlement-date loop over :func:`polymarket_settle`; for an expired event ``polymarket_settle`` @@ -1016,6 +1025,10 @@ def dataset( """ from mostlyright.research import dataset as _core_dataset + # The Polymarket entity IS the event id — alias so the body (and its error + # copy) stays byte-identical to the pre-rename delegator. + event_id = entity + if not isinstance(event_id, str) or not _EVENT_ID_RE.match(event_id): raise PolymarketEventError( f"event_id must be 1-128 chars of [A-Za-z0-9_-]; got {event_id!r}", @@ -1074,6 +1087,46 @@ def dataset( ) +def dataset( + event_id: str, + from_date: str, + to_date: str, + *, + outcome: bool = False, + event: dict[str, Any] | None = None, + client: httpx.Client | None = None, + now: datetime | None = None, + features: list[str] | tuple[str, ...] | None = None, + **core_kwargs: Any, +) -> pd.DataFrame: + """DEPRECATED shim → :func:`pairs` (Phase 34 06). + + ``polymarket.dataset()`` is renamed to :func:`polymarket.pairs` — "dataset" is + reserved for the catalog noun (what ``discover()`` returns). This shim forwards + verbatim (ZERO behavior change) and emits a ``DeprecationWarning``; removed at 2.0. + """ + import warnings + + warnings.warn( + "markets.polymarket.dataset() is deprecated; use markets.polymarket.pairs() " + "(same signature — 'dataset' is now reserved for the catalog noun). " + "Removal target: 2.0 (>=2 minor releases out).", + DeprecationWarning, + stacklevel=2, + ) + return pairs( + event_id, + from_date, + to_date, + outcome=outcome, + event=event, + client=client, + now=now, + features=features, + **core_kwargs, + ) + + def _append_polymarket_outcome( result: pd.DataFrame, *, @@ -1159,3 +1212,147 @@ def _append_polymarket_outcome( if errors: out.attrs["polymarket_settle_errors"] = errors return out + + +# --------------------------------------------------------------------------- +# Phase 34 34-04 — spine constructor + .label. settlement factory +# --------------------------------------------------------------------------- +# +# A spine is the primitive; a LABEL is a spine FACTORY over the SAME domain +# constructor (``contracts()``). At trade time there is no label, so serving uses +# the bare spine ``polymarket.contracts()``; training uses +# ``polymarket.label.settlement()`` built on that same constructor — so the +# settlement-calendar logic is minted in ONE place and backtest/live cannot drift +# (the skew guard). ``station_override`` is homed HERE (the Polymarket denylist +# backstop): it pins the settlement station directly, bypassing event-based +# resolution AND the network. + + +def _resolve_contract_station(event_id: str, station_override: str | None) -> str: + """Resolve the settlement station for a Polymarket contract spine. + + ``station_override`` (the orphan-kwarg home) pins the station directly and + bypasses event resolution AND the network. Otherwise the event's settlement + station is resolved via the shipped resolver (no duplicated station logic). + """ + if station_override is not None: + return station_override + + if not isinstance(event_id, str) or not _EVENT_ID_RE.match(event_id): + raise PolymarketEventError( + f"event_id must be 1-128 chars of [A-Za-z0-9_-]; got {event_id!r}", + ) + cached_event = fetch_event_by_id(event_id) + if not isinstance(cached_event, dict): + raise PolymarketEventError( + f"event payload must be a dict; got {type(cached_event).__name__}", + ) + city_map = load_polymarket_city_stations() + event_for_resolution = dict(cached_event) + if not event_for_resolution.get("city"): + event_for_resolution["city"] = _derive_city( + event_for_resolution, tuple(sorted(city_map.keys(), key=len, reverse=True)) + ) + icao, _measure = resolve_station_for_event(event_for_resolution, city_map) + return icao + + +def contracts( + event_id: str, + from_date: str, + to_date: str, + *, + station_override: str | None = None, +) -> pd.DataFrame: + """The bare Polymarket spine constructor (train == trade — the skew guard). + + TWO POSITIONAL DATES (inclusive ends, R-13). ``station_override=`` pins the + settlement station directly (the denylist backstop's home), bypassing event + resolution AND the network; otherwise the event's station is resolved via the + shipped resolver. The spine is one row per LST settlement day with NO y columns. + + PIT entity universe (T-34-13): the caller names the contract; PIT-correct + rolling-feature transforms are OUT of scope (computed inside sources or by users + post-``align`` at their own risk). + """ + from mostlyright.markets.kalshi import _spine_days + + station = _resolve_contract_station(event_id, station_override) + spine_df = _spine_days(station, from_date, to_date) + return spine_df[["station", *[c for c in spine_df.columns if c != "station"]]] + + +class _PolymarketLabel: + """The ``polymarket.label.*`` namespace — ``.label.``-registered factories.""" + + def settlement( + self, + event_id: str, + from_date: str, + to_date: str, + *, + station_override: str | None = None, + ) -> pd.DataFrame: + """The Polymarket settlement LABEL spine (WU/NOAA-WRH daily extremes). + + Builds on ``polymarket.contracts()`` and attaches ``daily_extremes_*`` y + columns byte-identical to the shipped recipe (via the + ``dataset(label="daily_extremes")`` oracle — ZERO duplicated join logic). + ``station_override`` pins the station (the denylist backstop). The frame + carries the label source-identity (id + version) on ``attrs``. Mints the PIT + entity universe; PIT-correct rolling-feature transforms are OUT of scope. + """ + + from mostlyright._labels import RECIPE_LABEL_COLUMNS + from mostlyright.contracts import SpineContract, validate_spine_frame + from mostlyright.markets.kalshi import _lift_recipe_y, _spine_days + + station = _resolve_contract_station(event_id, station_override) + y_columns = RECIPE_LABEL_COLUMNS["daily_extremes"] + + base = _spine_days(station, from_date, to_date) + colmap = _lift_recipe_y( + station, from_date, to_date, core_label="daily_extremes", y_columns=y_columns + ) + for col in y_columns: + base[col] = [colmap.get(day, {}).get(col) for day in base["date"]] + base["label_available_time"] = base["decision_time"] + + spine_df = base[["station", *[c for c in base.columns if c != "station"]]] + contract = SpineContract( + y_columns=y_columns, + label_source_id=_POLYMARKET_LABEL_ID, + entity_key=("station",), + ) + validate_spine_frame(spine_df, contract) + spine_df.attrs["label_source_id"] = _POLYMARKET_LABEL_ID + spine_df.attrs["label_version"] = contract.label_version + return spine_df + + +#: The ``polymarket.label`` namespace singleton (``polymarket.label.settlement(...)``). +label = _PolymarketLabel() + + +def _register_label() -> None: + """Register the Polymarket settlement label under its ``.label.`` id (import-time).""" + import contextlib + + from mostlyright import registry + from mostlyright.contracts import SourceContract + from mostlyright.core.exceptions import ContractError + + with contextlib.suppress(ContractError): + registry.label( + SourceContract( + id=_POLYMARKET_LABEL_ID, + prefix="daily_extremes_", + entity_key=("station",), + event_time_col="date", + knowledge_time_col="label_available_time", + pit_fidelity="reconstructed", + ) + ) + + +_register_label() diff --git a/packages/markets/tests/test_markets_label_axis.py b/packages/markets/tests/test_markets_label_axis.py new file mode 100644 index 00000000..22924baf --- /dev/null +++ b/packages/markets/tests/test_markets_label_axis.py @@ -0,0 +1,132 @@ +"""Phase 34 04 — RED/GREEN tests for the markets LABEL/SPINE axis (D-34.12). + +``markets.kalshi.contracts()`` / ``markets.polymarket.contracts()`` are the bare +spine constructors train and trade share; ``markets.kalshi.label.settlement()`` / +``markets.polymarket.label.settlement()`` are ``.label.``-registered settlement +factories. ``station_override`` is homed on ``polymarket.contracts()`` (the +denylist backstop). Both take TWO POSITIONAL DATES (inclusive ends, R-13). +""" + +from __future__ import annotations + +import importlib + +import pandas as pd + +_KALSHI_TICKER = "KXHIGHNY-25MAY26-T79" # → settlement station KNYC + + +def _fake_cli_dataset(*, station, from_date, to_date, label=None, tz_override=None, **kw): + dates = pd.date_range(from_date, to_date, freq="D") + n = len(dates) + return pd.DataFrame( + { + "station": [station] * n, + "cli_high_f": [70.0 + i for i in range(n)], + "cli_low_f": [50.0 + i for i in range(n)], + "cli_report_type": ["IS"] * n, + }, + index=pd.DatetimeIndex(dates, name="date"), + ) + + +def _fake_extremes_dataset(*, station, from_date, to_date, label=None, tz_override=None, **kw): + dates = pd.date_range(from_date, to_date, freq="D") + n = len(dates) + return pd.DataFrame( + { + "station": [station] * n, + "daily_extremes_tmax_c": [1.0 + i for i in range(n)], + "daily_extremes_tmin_c": [-1.0 - i for i in range(n)], + }, + index=pd.DatetimeIndex(dates, name="date"), + ) + + +# --------------------------------------------------------------------------- +# kalshi +# --------------------------------------------------------------------------- + + +def test_kalshi_contracts_returns_a_bare_spine() -> None: + """``kalshi.contracts(ticker, from, to)`` → a bare spine (station, decision_time, + no y) — the same constructor a label factory builds on.""" + from mostlyright.markets import kalshi + + out = kalshi.contracts(_KALSHI_TICKER, "2025-01-01", "2025-01-02") + + assert isinstance(out, pd.DataFrame) + assert next(iter(out.columns)) == "station" + assert "decision_time" in out.columns + assert not [c for c in out.columns if c.startswith(("cli_", "y_"))] + # Normalized registry code — the shared spine join-key convention + # (review-iter-3: an as-typed key NaN'd every weather.obs() feature). + assert list(out["station"]) == ["NYC", "NYC"] + + +def test_kalshi_label_settlement_returns_spine_with_y_and_source_identity(monkeypatch) -> None: + """``kalshi.label.settlement()`` returns a spine with the Kalshi-settled y + + label source-identity (id + version).""" + research_mod = importlib.import_module("mostlyright.research") + monkeypatch.setattr(research_mod, "dataset", _fake_cli_dataset) + + from mostlyright.markets import kalshi + + out = kalshi.label.settlement(_KALSHI_TICKER, "2025-01-01", "2025-01-02") + + assert "cli_high_f" in out.columns + assert "decision_time" in out.columns + assert "label_available_time" in out.columns + assert list(out["cli_high_f"]) == [70.0, 71.0] + # label source-identity is stamped on the frame + assert out.attrs.get("label_source_id") == "markets.kalshi.label.settlement" + assert "label_version" in out.attrs + + +# --------------------------------------------------------------------------- +# polymarket +# --------------------------------------------------------------------------- + + +def test_polymarket_contracts_threads_station_override() -> None: + """``station_override=`` pins the station directly (the denylist backstop's + home), bypassing event resolution AND the network.""" + from mostlyright.markets import polymarket + + out = polymarket.contracts("some-event-id", "2025-01-01", "2025-01-02", station_override="KLGA") + + assert isinstance(out, pd.DataFrame) + assert list(out["station"]) == ["LGA", "LGA"] # normalized registry code + assert "decision_time" in out.columns + + +def test_polymarket_label_settlement_returns_extremes_spine(monkeypatch) -> None: + """``polymarket.label.settlement()`` returns the WU/NOAA-WRH-settled spine.""" + research_mod = importlib.import_module("mostlyright.research") + monkeypatch.setattr(research_mod, "dataset", _fake_extremes_dataset) + + from mostlyright.markets import polymarket + + out = polymarket.label.settlement( + "some-event-id", "2025-01-01", "2025-01-02", station_override="KLGA" + ) + + assert "daily_extremes_tmax_c" in out.columns + assert list(out["daily_extremes_tmax_c"]) == [1.0, 2.0] + assert out.attrs.get("label_source_id") == "markets.polymarket.label.settlement" + + +# --------------------------------------------------------------------------- +# .label. registration invariant +# --------------------------------------------------------------------------- + + +def test_markets_label_factories_registered_under_dot_label() -> None: + """Both settlement factories register under a ``.label.`` id.""" + import mostlyright.markets.kalshi + import mostlyright.markets.polymarket # noqa: F401 + from mostlyright import registry + + entries = registry.entries() + assert "markets.kalshi.label.settlement" in entries + assert "markets.polymarket.label.settlement" in entries diff --git a/packages/weather/pyproject.toml b/packages/weather/pyproject.toml index 091ba2ae..ce3689fc 100644 --- a/packages/weather/pyproject.toml +++ b/packages/weather/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mostlyrightmd-weather" -version = "1.16.0" +version = "1.17.0" description = "Weather data for Python — live METAR (AWC), ASOS archive (IEM), historical observations (GHCNh), and NWS climate text products (CLI). For quants, ML training pipelines, and weather-bot agents. Direct public-API access, no hosted backend. Imports as `mostlyright.weather`." readme = "README.md" license = "MIT" diff --git a/packages/weather/src/mostlyright/weather/__init__.py b/packages/weather/src/mostlyright/weather/__init__.py index 6bc3fcb4..daa75d8d 100644 --- a/packages/weather/src/mostlyright/weather/__init__.py +++ b/packages/weather/src/mostlyright/weather/__init__.py @@ -36,25 +36,49 @@ from mostlyright.weather._fetchers._open_meteo import fetch_open_meteo from mostlyright.weather.climate import climate as climate # Phase 30 (30-02) surface +from mostlyright.weather.forecasts import forecasts as forecasts # Phase 34 (34-04) source from mostlyright.weather.obs import obs as obs # re-export Phase 7 public surface +from mostlyright.weather.pairs import pairs as pairs # Phase 34 (34-06) quickstart from mostlyright.weather.satellite import satellite as satellite # Phase 25 surface +from mostlyright.weather.spine import days as days # Phase 34 (34-04) bare spine (R-03) if TYPE_CHECKING: from mostlyright.weather import cwop as cwop + from mostlyright.weather import label as label + from mostlyright.weather import stations as stations try: __version__ = _dist_version("mostlyrightmd-weather") except PackageNotFoundError: # editable/source tree without installed dist metadata __version__ = "0.0.0+unknown" -__all__ = ["__version__", "climate", "cwop", "fetch_open_meteo", "obs", "satellite"] +__all__ = [ + "__version__", + "climate", + "cwop", + "dataset", + "days", + "fetch_open_meteo", + "forecasts", + "label", + "live", + "obs", + "pairs", + "research", + "satellite", + "stations", +] def __getattr__(name: str) -> Any: - """Lazily expose the standalone ``cwop`` submodule (PEP 562). + """Lazily expose weather submodules + the canonical relocated surface (PEP 562). - Importing ``cwop`` registers ``schema.cwop.v1`` and pulls in the APRS - transport, so it is deferred until first access — ``import - mostlyright.weather`` stays cheap for callers who never touch CWOP. + Phase 34 06 namespace inversion: ``Station``/``CATALOG``/``StationCatalog``, + ``research``/``dataset``, and ``live`` move OUT of the machinery-only root and + get their canonical homes here (``weather.stations`` / ``weather.research`` / + ``weather.dataset`` / ``weather.live``). The root symbols become deprecated + shims that delegate to these. ``research``/``dataset`` resolve to the SAME + function objects the core ``mostlyright.research`` module owns (byte-stable — + the move is a re-export, not a code-path change). """ if name == "cwop": import importlib @@ -63,4 +87,28 @@ def __getattr__(name: str) -> Any: # package), so it does NOT re-enter __getattr__ the way # ``from mostlyright.weather import cwop`` would. return importlib.import_module("mostlyright.weather.cwop") + if name == "label": + import importlib + + # The label subpackage registers ``weather.label.*`` on import; defer it + # until first access so ``import mostlyright.weather`` stays cheap. + return importlib.import_module("mostlyright.weather.label") + if name == "stations": + import importlib + + return importlib.import_module("mostlyright.weather.stations") + if name == "live": + import importlib + + # Canonical home for the live surface — the core ``mostlyright.live`` + # module (the root ``mostlyright.live`` shim points here). + return importlib.import_module("mostlyright.live") + if name in ("research", "dataset"): + # Byte-stable re-export of the core composition functions. Resolve the + # MODULE via import (sys.modules-cached) so we hand back the SAME function + # object callers/tests patch — never a copy. + import importlib + + research_mod = importlib.import_module("mostlyright.research") + return getattr(research_mod, name) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/packages/weather/src/mostlyright/weather/forecasts.py b/packages/weather/src/mostlyright/weather/forecasts.py new file mode 100644 index 00000000..aa47bd42 --- /dev/null +++ b/packages/weather/src/mostlyright/weather/forecasts.py @@ -0,0 +1,183 @@ +"""``weather.forecasts()`` — the registered bitemporal forecast source (Phase 34 04). + +``forecasts(entity, from_date=None, to_date=None, *, source='iem_mos', model=None, +models=None)`` is a WRAP-AND-REGISTER bitemporal source: it WRAPS the shipped +``dataset(include_forecast=True)`` forecast join (NO new fetcher, NO changed join) +and registers a :class:`SourceContract`, so the obs+forecast NHIGH training set is +buildable via ``align(weather.label.cli(...), weather.obs(...), +weather.forecasts(...))``. It is the destination for the four migrated forecast +kwargs: + +- ``forecast_source`` → ``source=`` (``iem_mos`` default vs ``nwp``), +- ``forecast_model`` → ``model=``, +- ``forecast_models`` → ``models=``, +- ``include_forecast`` → mere PRESENCE in ``align()``'s ``*sources``. + +Window-optional (34-03 convention): a dateless ``forecasts('KNYC')`` returns a +:class:`~mostlyright.align.LazySourceSpec` (``align`` infers the fetch window from +the spine's ``decision_time`` bounds); a windowed call returns the eager +``fcst_*`` frame. Because the wrap goes through ``dataset(include_forecast=True)`` +— the shipped byte-consistency ORACLE — the ``fcst_*`` columns are byte-consistent +with that join by construction (T-34-15). The contract declares +``pit_fidelity="reconstructed"`` (mirroring the 34-03 obs source): ``align`` +equality-joins on the ``(station, date)`` key and byte-passes the ``fcst_*`` +columns through unchanged (a source legitimately owns its ``fcst_`` prefix). +""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pandas as pd + from mostlyright.align import LazySourceSpec + from mostlyright.contracts import SourceContract + +__all__ = ["forecasts"] + +#: Source identity + column namespace for the forecast source. ``fcst_`` is the +#: source's own (non-forbidden) prefix — the columns ``dataset(include_forecast= +#: True)`` already emits. +_FORECAST_SOURCE_ID = "weather.forecasts" +_FORECAST_PREFIX = "fcst_" + + +def _forecast_contract() -> SourceContract: + """The :class:`SourceContract` a materialized ``forecasts()`` frame conforms to.""" + from mostlyright.contracts import SourceContract + + return SourceContract( + id=_FORECAST_SOURCE_ID, + prefix=_FORECAST_PREFIX, + entity_key=("station",), + event_time_col="date", + knowledge_time_col="date", + pit_fidelity="reconstructed", + ) + + +def _resolve_dataset(): + """Resolve the core ``dataset()`` callable through ``sys.modules``. + + ``mostlyright.__init__`` binds a ``research`` FUNCTION attribute that shadows + the ``mostlyright.research`` submodule on attribute access, so resolving the + MODULE via ``sys.modules`` guarantees the wrapped ``dataset()`` is the same + object callers/tests patch (the shipped markets wrappers use this pattern). + """ + mod = sys.modules.get("mostlyright.research") + if mod is None: + import importlib + + mod = importlib.import_module("mostlyright.research") + return mod.dataset + + +def _forecast_frame( + entity: str, + from_date: str, + to_date: str, + *, + source: str, + model: str | None, + models: list[str] | None, +) -> pd.DataFrame: + """Materialize the ``fcst_*`` source frame by WRAPPING ``dataset(include_forecast=True)``. + + Threads the migrated kwargs onto their existing core homes and lifts the + ``fcst_*`` columns (keyed by ``station`` + the ISO settlement ``date``) — NO + new fetcher, NO reimplemented join. + """ + import pandas as pd + + dataset = _resolve_dataset() + kwargs: dict = { + "include_forecast": True, + "forecast_source": source, + "label": None, + } + if models is not None: + kwargs["forecast_models"] = models + elif model is not None: + kwargs["forecast_model"] = model + + composed = dataset(station=entity, from_date=from_date, to_date=to_date, **kwargs) + + # Bring the settlement-day index into a column and normalize to ISO strings + # (the equality-join key the spine carries). + flat = composed.reset_index() + date_col = "date" if "date" in flat.columns else flat.columns[0] + dates_iso = [pd.Timestamp(v).date().isoformat() for v in flat[date_col]] + + fcst_cols = [c for c in composed.columns if c.startswith(_FORECAST_PREFIX)] + out = pd.DataFrame( + { + "station": ( + list(flat["station"]) if "station" in flat.columns else [entity] * len(flat) + ), + "date": dates_iso, + } + ) + for col in fcst_cols: + out[col] = list(composed[col]) + return out + + +def forecasts( + entity: str, + from_date: str | None = None, + to_date: str | None = None, + *, + source: str = "iem_mos", + model: str | None = None, + models: list[str] | None = None, +) -> pd.DataFrame | LazySourceSpec: + """Return the forecast source frame — or a lazy spec when the window is omitted. + + Args: + entity: The station id to fetch forecasts for. + from_date, to_date: Inclusive ISO window. BOTH omitted → a + :class:`LazySourceSpec` (``align`` infers the window); BOTH supplied → + the eager ``fcst_*`` frame. + source: ``forecast_source`` — ``"iem_mos"`` (default) or ``"nwp"``. + model: ``forecast_model`` — a single model selection. + models: ``forecast_models`` — a multi-model fan-out (mutually exclusive + with ``model``). + + Returns: + A ``pd.DataFrame`` (windowed) or a :class:`LazySourceSpec` (dateless). + """ + if from_date is None and to_date is None: + from mostlyright.align import LazySourceSpec + + def _builder(e: str, fd: str, td: str) -> pd.DataFrame: + return _forecast_frame(e, fd, td, source=source, model=model, models=models) + + return LazySourceSpec(contract=_forecast_contract(), entity=entity, builder=_builder) + + if from_date is None or to_date is None: + raise ValueError( + "forecasts() requires both from_date and to_date, or neither (a " + f"dateless call returns a lazy source spec); got from_date={from_date!r}, " + f"to_date={to_date!r}" + ) + + return _forecast_frame(entity, from_date, to_date, source=source, model=model, models=models) + + +def _register() -> None: + """Register ``weather.forecasts`` as a SOURCE in the catalog (import-time). + + An identical re-registration is a no-op in the registry; the ``suppress`` only + guards an import-order edge where the source is already present. + """ + import contextlib + + from mostlyright import registry + from mostlyright.core.exceptions import ContractError + + with contextlib.suppress(ContractError): + registry.source(_forecast_contract()) + + +_register() diff --git a/packages/weather/src/mostlyright/weather/label/__init__.py b/packages/weather/src/mostlyright/weather/label/__init__.py new file mode 100644 index 00000000..3cff5395 --- /dev/null +++ b/packages/weather/src/mostlyright/weather/label/__init__.py @@ -0,0 +1,241 @@ +"""``weather.label.*`` — the ``.label.``-registered weather spine FACTORIES (Phase 34 04). + +A spine is the primitive; a LABEL is a spine FACTORY over the SAME domain +constructor (``weather.days()``). At trade time there is no label, so serving uses +a bare spine; training uses a ``.label.`` factory built on that same constructor — +so the settlement-calendar logic is minted in ONE place and backtest/live cannot +drift (the skew guard). + +- ``cli(entity, from_date, to_date)`` — TWO POSITIONAL DATES (R-13). Builds on + ``weather.days()`` and attaches y columns byte-identical to the shipped cli + recipe (``apply_named_recipe`` — reached through the byte-consistency oracle + ``dataset(label="cli")``). y columns keep their native names (``cli_high_f``, + ``cli_low_f``, ``cli_report_type``). +- ``daily_extremes(entity, from_date, to_date)`` — TWO POSITIONAL DATES. Wraps + ``compose_daily_extremes_label`` (the WU/NOAA-WRH extremes recipe Polymarket + resolves on) directly; y columns keep their native ``daily_extremes_*`` names. + +Both register via ``registry.label()`` under ``weather.label.cli`` / +``weather.label.daily_extremes`` (the ``.label.`` invariant: registering either as +a SOURCE raises). On a mixed-venue LIST spine the recipe resolves PER STATION. + +Threat T-34-14 (firewall + parity): the factories WRAP the byte-stable recipes — +the climate dedup + the WU/NOAA-WRH precision path are untouched, and CWOP is +NEVER a label source. Threat T-34-13 (universe leakage): the factory mints the PIT +entity universe for the stations the caller names; PIT-correct rolling-feature +transforms are OUT of scope. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from mostlyright._labels import ( + RECIPE_LABEL_COLUMNS, + apply_named_recipe, # noqa: F401 — reached transitively via dataset(label="cli") + compose_daily_extremes_label, +) + +if TYPE_CHECKING: + import pandas as pd + +__all__ = ["cli", "daily_extremes"] + + +def _index_dates_iso(frame: pd.DataFrame) -> list[str]: + """The frame's index dates as ISO ``YYYY-MM-DD`` strings (mirrors _labels).""" + import pandas as pd + + return [ + (v if isinstance(v, pd.Timestamp) else pd.Timestamp(v)).date().isoformat() + for v in frame.index + ] + + +def _lift_by_date(frame: pd.DataFrame, columns: tuple[str, ...]) -> dict[str, dict[str, Any]]: + """Map a recipe-composed frame (date-indexed) to ``{date_iso: {col: value}}``.""" + dates_iso = _index_dates_iso(frame) + out: dict[str, dict[str, Any]] = {} + for i, day in enumerate(dates_iso): + out[day] = {col: frame[col].iloc[i] for col in columns if col in frame.columns} + return out + + +def _cli_recipe_columns( + station: str, from_date: str, to_date: str, tz_override: str | None +) -> dict[str, dict[str, Any]]: + """Byte-identical cli_* columns via the shipped oracle ``dataset(label="cli")``. + + ``dataset(label="cli")`` applies ``apply_named_recipe`` internally, so this wrap + is byte-identical to the recipe output by construction (no reimplemented join). + Resolved through ``sys.modules`` so the wrapped ``dataset()`` is the same object + callers/tests patch. + """ + mod = sys.modules.get("mostlyright.research") + if mod is None: + import importlib + + mod = importlib.import_module("mostlyright.research") + composed = mod.dataset( + station=station, + from_date=from_date, + to_date=to_date, + label="cli", + tz_override=tz_override, + ) + return _lift_by_date(composed, RECIPE_LABEL_COLUMNS["cli"]) + + +def _daily_extremes_recipe_columns( + station: str, from_date: str, to_date: str, tz_override: str | None +) -> dict[str, dict[str, Any]]: + """Byte-identical daily_extremes_* columns via ``compose_daily_extremes_label``. + + Builds a settlement-day-indexed frame and runs the recipe directly (it computes + its columns from ``mostlyright.international.daily_extremes`` — no pre-composed + frame needed), so the y columns are byte-identical to the shipped recipe. + """ + import pandas as pd + from mostlyright._internal._pairs import date_range + + idx = pd.DatetimeIndex([pd.Timestamp(d) for d in date_range(from_date, to_date)], name="date") + result = pd.DataFrame(index=idx) + composed = compose_daily_extremes_label( + result, + station=station, + from_date=from_date, + to_date=to_date, + tz_override=tz_override, + ) + return _lift_by_date(composed, RECIPE_LABEL_COLUMNS["daily_extremes"]) + + +def _build_label_spine( + entity: str | list[str] | tuple[str, ...], + from_date: str, + to_date: str, + *, + y_columns: tuple[str, ...], + columns_fn: Callable[[str, str, str, str | None], dict[str, dict[str, Any]]], + label_source_id: str, + tz_override: str | None, +) -> pd.DataFrame: + """Build a spine via ``weather.days()`` then attach the recipe's y columns. + + The recipe frame is one row per settlement day; its columns are lifted onto the + ``days()`` spine keyed by the ISO settlement ``date`` (per-station on a list + spine, so a mixed-venue panel resolves each recipe on its own calendar). + """ + import pandas as pd + from mostlyright.contracts import SpineContract, validate_spine_frame + from mostlyright.weather.spine import days + + stations = [entity] if isinstance(entity, str) else list(entity) + frames: list[pd.DataFrame] = [] + for station in stations: + base = days(station, from_date, to_date, tz_override=tz_override) + colmap = columns_fn(station, from_date, to_date, tz_override) + for col in y_columns: + base[col] = [colmap.get(day, {}).get(col) for day in base["date"]] + # Conservative default: the label is known no earlier than the decision. + base["label_available_time"] = base["decision_time"] + frames.append(base) + + spine_df = pd.concat(frames, ignore_index=True) + ordered = ["station", *[c for c in spine_df.columns if c != "station"]] + spine_df = spine_df[ordered] + + contract = SpineContract( + y_columns=y_columns, + label_source_id=label_source_id, + entity_key=("station",), + ) + validate_spine_frame(spine_df, contract) + spine_df.attrs["label_source_id"] = label_source_id + spine_df.attrs["label_version"] = contract.label_version + return spine_df + + +def cli( + entity: str | list[str] | tuple[str, ...], + from_date: str, + to_date: str, + *, + tz_override: str | None = None, +) -> pd.DataFrame: + """The cli settlement LABEL spine (Kalshi NHIGH/NLOW ground truth). + + Two positional dates (inclusive ends). Builds on ``weather.days()`` and attaches + ``cli_*`` y columns byte-identical to the shipped cli recipe. Mints the PIT + entity universe for the named station(s); PIT-correct rolling-feature transforms + are OUT of scope (computed inside sources or by users post-``align``). + """ + return _build_label_spine( + entity, + from_date, + to_date, + y_columns=RECIPE_LABEL_COLUMNS["cli"], + columns_fn=_cli_recipe_columns, + label_source_id="weather.label.cli", + tz_override=tz_override, + ) + + +def daily_extremes( + entity: str | list[str] | tuple[str, ...], + from_date: str, + to_date: str, + *, + tz_override: str | None = None, +) -> pd.DataFrame: + """The daily_extremes settlement LABEL spine (Polymarket WU/NOAA-WRH ground truth). + + Two positional dates (inclusive ends). Wraps ``compose_daily_extremes_label`` and + attaches ``daily_extremes_*`` y columns byte-identical to the shipped recipe. + Mints the PIT entity universe for the named station(s); PIT-correct + rolling-feature transforms are OUT of scope. + """ + return _build_label_spine( + entity, + from_date, + to_date, + y_columns=RECIPE_LABEL_COLUMNS["daily_extremes"], + columns_fn=_daily_extremes_recipe_columns, + label_source_id="weather.label.daily_extremes", + tz_override=tz_override, + ) + + +def _register() -> None: + """Register both factories under their ``.label.`` ids (import-time). + + An identical re-registration is a no-op in the registry; ``suppress`` only + guards an import-order edge where the label is already present. + """ + import contextlib + + from mostlyright import registry + from mostlyright.contracts import SourceContract + from mostlyright.core.exceptions import ContractError + + specs = ( + ("weather.label.cli", "cli_"), + ("weather.label.daily_extremes", "daily_extremes_"), + ) + for label_id, prefix in specs: + with contextlib.suppress(ContractError): + registry.label( + SourceContract( + id=label_id, + prefix=prefix, + entity_key=("station",), + event_time_col="date", + knowledge_time_col="label_available_time", + pit_fidelity="reconstructed", + ) + ) + + +_register() diff --git a/packages/weather/src/mostlyright/weather/obs.py b/packages/weather/src/mostlyright/weather/obs.py index b940cfeb..a2b21fb8 100644 --- a/packages/weather/src/mostlyright/weather/obs.py +++ b/packages/weather/src/mostlyright/weather/obs.py @@ -29,6 +29,8 @@ if TYPE_CHECKING: import pandas as pd + from mostlyright.align import LazySourceSpec + from mostlyright.contracts import SourceContract Source = Literal["iem", "ghcnh", "awc"] Strategy = Literal["auto", "exact_window", "warm_cache", "hosted"] @@ -70,9 +72,9 @@ def obs( - station: str, - start: str, - end: str, + station: str | list[str] | tuple[str, ...], + from_date: str | None = None, + to_date: str | None = None, *, source: Source | None = None, strategy: Strategy = "auto", @@ -81,18 +83,28 @@ def obs( backend: str = "pandas", return_type: str = "dataframe", tz_override: str | None = None, -) -> pd.DataFrame | list[dict]: - """Return observation data for ``station`` over ``[start, end]``. - - Returns default fused-source aggregates merged via SOURCE_PRIORITY - (AWC > IEM > GHCNh). ``granularity`` chooses the row level. + qc: bool = False, +) -> pd.DataFrame | list[dict] | LazySourceSpec: + """Return observation data for ``station`` over ``[from_date, to_date]``. + + Window-optional (R-02): when BOTH ``from_date`` and ``to_date`` are ``None`` + the call is a window-DEFERRED source builder and returns a + :class:`~mostlyright.align.LazySourceSpec` — so ``align(spine, + weather.obs('KNYC'))`` never raises ``TypeError`` (``align`` infers the fetch + window from the spine's ``decision_time`` bounds and re-enters this eager path + verbatim). With a window supplied it returns default fused-source aggregates + merged via SOURCE_PRIORITY (AWC > IEM > GHCNh) — byte-identical to prior + positional-date releases. ``granularity`` chooses the row level. Parameters ---------- station : str ICAO code (e.g. ``"KNYC"``) or 3-letter NWS code. - start, end : str - ISO date strings (YYYY-MM-DD), inclusive bounds. + from_date, to_date : str | None + ISO date strings (YYYY-MM-DD), inclusive bounds. When BOTH are ``None`` + the call returns a lazy source spec (window inferred by ``align``); + otherwise BOTH must be supplied. (Renamed from ``start``/``end`` in R-02; + every positional caller is unaffected.) source : {"iem", "ghcnh", "awc"} | None, keyword-only If set, only that source is queried; the other two fetchers are skipped. If None, all three are queried and merged with the standard priority @@ -181,6 +193,20 @@ def obs( >>> hourly = obs("KNYC", "2024-03-01", "2024-03-31", ... granularity="observation") # doctest: +SKIP """ + # R-02 window-optional: a dateless call is a window-DEFERRED source builder. + # Return a LazySourceSpec whose builder re-enters THIS eager path with the + # align-inferred window, so the eager code below is reused verbatim + # (byte-stable). A half-open window (exactly one date) is a caller error. + if from_date is None and to_date is None: + return _lazy_obs_spec(station, source=source, strategy=strategy, qc=qc) + if from_date is None or to_date is None: + raise ValueError( + "obs() requires both from_date and to_date, or neither (a dateless " + f"call returns a lazy source spec); got from_date={from_date!r}, " + f"to_date={to_date!r}" + ) + start, end = from_date, to_date + if source is not None and source not in _VALID_SOURCES: raise ValueError(f"source must be one of {sorted(_VALID_SOURCES)} or None; got {source!r}") if strategy not in _VALID_STRATEGIES: @@ -607,4 +633,79 @@ def _resolve_strategy( return "warm_cache" +#: Canonical short id + prefix for the fused observation source when composed +#: through ``align()`` (R-09: ``met_``, a source-owned namespace — NOT the +#: LABEL-only ``obs_``). Daily obs aggregates are the highest-authority-available +#: fused view (AWC>IEM>GHCNh), so the contract is ``pit_fidelity="reconstructed"``. +_OBS_SOURCE_ID = "met" +_OBS_PREFIX = "met_" + + +def _obs_source_contract(station: str | list[str] | tuple[str, ...]) -> SourceContract: + """The :class:`SourceContract` a lazily-materialized ``obs()`` frame conforms to. + + ``met_`` prefix (source-owned, non-forbidden), keyed per station, + ``pit_fidelity="reconstructed"`` (the fused daily aggregate is knowingly the + highest-authority-available view, mirroring the CLI-label composition). + """ + from mostlyright.contracts import SourceContract + + return SourceContract( + id=_OBS_SOURCE_ID, + prefix=_OBS_PREFIX, + entity_key=("station",), + event_time_col="date", + knowledge_time_col="date", + pit_fidelity="reconstructed", + ) + + +def _lazy_obs_spec( + station: str | list[str] | tuple[str, ...], + *, + source: Source | None, + strategy: Strategy, + qc: bool = False, +) -> LazySourceSpec: + """Build the window-deferred :class:`LazySourceSpec` for a dateless ``obs()`` call. + + The builder re-enters ``obs()`` eagerly with the align-inferred window, so the + eager fetch path is reused verbatim (byte-stable). ``align`` supplies the + inclusive ISO ``from_date``/``to_date`` from the spine's window bounds. ``qc`` + (34-04 orphan-kwarg home) is threaded through the builder so a dateless + ``obs(qc=True)`` carries the QC option into the align-materialized fetch. + + A LIST entity (``weather.pairs(["KNYC", "KLAX"], ...)`` desugars to + ``align(spine, weather.obs(["KNYC", "KLAX"]))``) materializes per station + through the same eager path and concatenates into one long-format panel — + ONE obs source, so the ``met_`` namespace is claimed exactly once + (review-iter-2). + """ + from mostlyright.align import LazySourceSpec + + def _builder( + entity: str | list[str] | tuple[str, ...], from_date: str, to_date: str + ) -> pd.DataFrame: + stations = [entity] if isinstance(entity, str) else list(entity) + # Per-station materialize + concat: a list entity re-entering the eager + # single-station path crashes before fetch (review-iter-2). Each eager + # frame carries the normalized registry code ("NYC") — the same form + # the days()-built spine keys on, so the equality join matches. + frames = [ + obs(s, from_date, to_date, source=source, strategy=strategy, qc=qc) for s in stations + ] + if len(frames) == 1: + # The eager daily path returns a DataFrame under the default backend. + return frames[0] # type: ignore[return-value] + import pandas as pd # deferred — this module keeps pandas off import path + + return pd.concat(frames, ignore_index=True) # type: ignore[arg-type] + + return LazySourceSpec( + contract=_obs_source_contract(station), + entity=station, + builder=_builder, + ) + + __all__ = ["Granularity", "Source", "Strategy", "obs"] diff --git a/packages/weather/src/mostlyright/weather/pairs.py b/packages/weather/src/mostlyright/weather/pairs.py new file mode 100644 index 00000000..36d50efe --- /dev/null +++ b/packages/weather/src/mostlyright/weather/pairs.py @@ -0,0 +1,95 @@ +"""``weather.pairs()`` — the rung-3 quickstart one-liner (Phase 34 06). + +``pairs(entity, from_date, to_date, *, label="cli")`` is the leakage-free +training-pair quickstart (the v0.14.1 ``client.pairs()`` heritage): TWO positional +dates, a CAPPED keyword surface, and a docstring that shows its OWN desugaring one +rung down (spine + ``align``). The ``label="cli"`` default is EXPLICIT, documented, +and contractually stable — it is NOT the dead D-22 flip (which was a default that +would silently change at 2.0 behind a ``FutureWarning``); ``pairs()``'s default +carries no warning and no deprecation path. + +The name ``dataset`` is BANNED as a NEW function: "dataset" is reserved for the +catalog noun (what ``discover()`` returns). Outgrowing the cap does NOT add a +kwarg — you GRADUATE to the explicit spine + ``align`` form (rung 2), which the +unsupported-kwarg error names at the moment it happens. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pandas as pd + +__all__ = ["pairs"] + + +def pairs( + entity: str | list[str] | tuple[str, ...], + from_date: str, + to_date: str, + *, + label: str = "cli", + **_unsupported: object, +) -> pd.DataFrame: + """Leakage-free training pairs with the ``label="cli"`` settlement target (quickstart). + + The ``label="cli"`` default is the Kalshi NHIGH/NLOW settlement ground truth — an + EXPLICIT, contractually-stable quickstart default (no ``FutureWarning``, no + deprecation path; NOT the removed D-22 flip). Two positional dates, inclusive ends. + + This one-liner DESUGARS to the rung-2 spine + ``align`` form — hold any intermediate + frame yourself by writing it out:: + + spine = weather.label.cli(entity, from_date, to_date) # a weather.days()-built spine + obs = weather.obs(entity) # the feature source (lazy window) + table = align(spine, obs) # == weather.pairs(entity, from_date, to_date) + + i.e. ``align(weather.label.cli(entity, from_date, to_date), weather.obs(entity, from_date, + to_date))``. The spine is minted by ``weather.days(...)`` (the PUBLIC bare-spine + re-export) so train and trade share ONE settlement calendar. ``label="daily_extremes"`` + swaps in the Polymarket WU/NOAA-WRH target. + + Args: + entity: A station id (or a list → a long-format panel, per ``weather.days``). + from_date: ``YYYY-MM-DD`` inclusive start. + to_date: ``YYYY-MM-DD`` inclusive end. + label: The settlement label recipe — ``"cli"`` (default) or ``"daily_extremes"``. + + Returns: + The aligned training table (spine ``y`` columns + the ``obs_*`` + observation aggregates — the v0.14.1 ``client.pairs()`` heritage + naming; the obs source is label-adjacent and passes through + un-prefixed), stamped ``attrs["source"] = "align"``. + + Raises: + TypeError: an unsupported keyword was passed — ``pairs()`` is capped. The + message names the graduation path (spine + ``align``) and the migration + guide; it is never a bare "unexpected keyword argument" error. + """ + if _unsupported: + bad = next(iter(_unsupported)) + raise TypeError( + f"pairs() is a capped quickstart; {bad!r} is not supported here. " + "Graduate to spine + align: weather.days(...) -> weather.label.(...) " + "-> align(spine, weather.obs(...)). See the migration guide." + ) + + from mostlyright.align import align + from mostlyright.weather import label as _label_ns + from mostlyright.weather import obs as _obs + + label_factory = getattr(_label_ns, label, None) + if label_factory is None or not callable(label_factory): + raise TypeError( + f"pairs(): unknown label {label!r}. Supported quickstart labels: " + "'cli' (Kalshi NHIGH/NLOW settlement) or 'daily_extremes' (Polymarket WU/NOAA-WRH). " + "For any other target, graduate to spine + align: " + "weather.days(...) -> weather.label.(...) -> align(spine, weather.obs(...)). " + "See the migration guide." + ) + + spine = label_factory(entity, from_date, to_date) + # ``weather.obs(entity)`` window-less is a LazySourceSpec — align materializes it + # from the spine's decision_time bounds (R-02), so the window is typed once. + return align(spine, _obs(entity)) diff --git a/packages/weather/src/mostlyright/weather/spine.py b/packages/weather/src/mostlyright/weather/spine.py new file mode 100644 index 00000000..0579c8c3 --- /dev/null +++ b/packages/weather/src/mostlyright/weather/spine.py @@ -0,0 +1,98 @@ +"""``weather.days()`` — the bare per-settlement-day weather spine (Phase 34 04). + +``days(stations, from_date, to_date, *, tz_override=None)`` mints the settlement +calendar for one or more weather stations and returns a BARE spine (entity = +``station``, a per-LST-settlement-day ``decision_time``, NO y columns). It is the +single domain spine constructor that SERVING (a bare spine) and TRAINING (a +``weather.label.*`` factory built on the same constructor) share — the structural +fix for settlement-calendar skew re-entering between backtest and live (the skew +guard). This module stays INTERNAL; the public entry point is the re-export +``weather.days()`` from ``weather/__init__.py`` (R-03). + +- **Two positional dates, inclusive ends (R-13):** ``days('KNYC', '2025-01-01', + '2025-01-03')`` enumerates 3 settlement days. +- **``tz_override`` is the orphan-kwarg home:** it moves the settlement calendar, + so the same day resolves a different ``decision_time`` instant (the market-close + UTC time for that station's LST calendar). +- **A LIST ``stations=`` → a LONG-FORMAT panel (R-04):** per-station calendars + concatenated with the ``station`` column FIRST, byte-equal to the shipped + ``dataset(stations=[...])`` per-station loop. Rows key by entity, so there is no + cross-station bleed. + +PIT entity universe (T-34-13): ``days()`` mints the calendar for the stations the +caller names — it is the caller's responsibility that the named stations existed +across the window. PIT-correct rolling-feature transforms are OUT of scope (they +are computed inside sources or by users post-``align`` at their own risk). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pandas as pd + +__all__ = ["days"] + + +def _station_key(station: str) -> str: + """The spine's join-key form of ``station`` — the shared normalized + registry code (see :func:`mostlyright._internal._pairs.station_key`; the + markets spine constructors use the same helper, review-iter-3).""" + from mostlyright._internal._pairs import station_key + + return station_key(station) + + +def days( + stations: str | list[str] | tuple[str, ...], + from_date: str, + to_date: str, + *, + tz_override: str | None = None, +) -> pd.DataFrame: + """Return a bare per-LST-settlement-day spine for ``stations``. + + Args: + stations: A single station id (``"KNYC"``) or a LIST of ids + (``["KNYC", "KLGA"]`` → a long-format panel). + from_date, to_date: Inclusive ISO ``YYYY-MM-DD`` window (two positional + dates, R-13). + tz_override: IANA timezone override for the settlement calendar (the + ``tz_override`` orphan-kwarg home) — moves the ``decision_time`` instant. + + Returns: + A DataFrame with ``station`` (the NORMALIZED registry code — ``"NYC"`` + for a ``"KNYC"`` input; the v0.14.1 heritage key every source frame and + ``research()``/``pairs()`` output carries, so align's equality join + matches — review-iter-2), ``date`` (the ISO settlement day, the + equality-join key sources attach on), and ``decision_time`` (the + market-close UTC instant for that settlement day). NO y columns — this is + a bare spine. An out-of-registry station (the ``tz_override`` path) + keeps its id as typed. + """ + import pandas as pd + from mostlyright._internal._pairs import date_range, market_close_utc + + station_list = [stations] if isinstance(stations, str) else list(stations) + + frames: list[pd.DataFrame] = [] + day_isos = date_range(from_date, to_date) + for station in station_list: + rows = [ + { + "station": _station_key(station), + "date": day_iso, + "decision_time": pd.Timestamp( + market_close_utc(day_iso, station, tz_override=tz_override) + ), + } + for day_iso in day_isos + ] + frames.append(pd.DataFrame(rows, columns=["station", "date", "decision_time"])) + + panel = pd.concat(frames, ignore_index=True) + # Long-format convention: station column FIRST (already constructed that way, + # reaffirmed for symmetry with dataset(stations=[...])). + ordered = ["station", *[c for c in panel.columns if c != "station"]] + return panel[ordered] diff --git a/packages/weather/src/mostlyright/weather/stations.py b/packages/weather/src/mostlyright/weather/stations.py new file mode 100644 index 00000000..8eb6f7f2 --- /dev/null +++ b/packages/weather/src/mostlyright/weather/stations.py @@ -0,0 +1,21 @@ +"""``mostlyright.weather.stations`` — the canonical home for the station catalog (Phase 34 06). + +The namespace inversion (D-34.20/34-CONTEXT) moves ``Station`` / ``CATALOG`` / +``StationCatalog`` out of the machinery-only root and under the weather domain: a +station is a weather fact, not root machinery. This module is the canonical public +home the root ``mostlyright.Station`` / ``mostlyright.CATALOG`` / +``mostlyright.StationCatalog`` shims delegate to (with a ``DeprecationWarning``). + +Split-distribution firewall (REQUIREMENTS.md RESEARCH-05): the catalog IMPLEMENTATION +stays in core (``mostlyright.stations`` / ``mostlyright._internal._stations``) so +``mostlyrightmd`` never gains a runtime dependency on ``mostlyrightmd-weather``. +This module re-exports that implementation in the ``weather`` direction +(``weather`` already depends on ``core``), giving the symbols their canonical +public home under ``mr.weather.stations`` without inverting the dependency graph. +""" + +from __future__ import annotations + +from mostlyright.stations import CATALOG, Station, StationCatalog + +__all__ = ["CATALOG", "Station", "StationCatalog"] diff --git a/packages/weather/tests/test_pairs.py b/packages/weather/tests/test_pairs.py new file mode 100644 index 00000000..e542236c --- /dev/null +++ b/packages/weather/tests/test_pairs.py @@ -0,0 +1,190 @@ +"""Phase 34 06 — RED/GREEN tests for the per-domain ``pairs()`` quickstart. + +``pairs(entity, from_date, to_date, *, label="cli")`` is the rung-3 one-liner: +capped kwargs, TWO positional dates, and a docstring that shows its own +spine+align desugaring (``weather.days(...)`` -> ``weather.label.cli(...)`` -> +``align(...)``). The ``label="cli"`` default is an explicit, contractually-stable +quickstart default (NOT the dead D-22 flip). Outgrowing the cap GRADUATES to +spine+align via a visible, targeted error (never a bare TypeError). + +``dataset`` is banned as a NEW function name — the old ``markets.*.dataset()`` +delegators become deprecation shims routing to ``pairs()``. +""" + +from __future__ import annotations + +import inspect +import warnings + +import pytest + +# --------------------------------------------------------------------------- +# weather.pairs — shape, cap, and desugaring docstring. +# --------------------------------------------------------------------------- + + +def test_weather_pairs_exists_and_is_callable() -> None: + import mostlyright.weather as w + + assert callable(w.pairs) + + +def test_weather_pairs_two_positional_dates_and_capped() -> None: + import mostlyright.weather as w + + sig = inspect.signature(w.pairs) + params = list(sig.parameters) + # Capped: no more than 5 parameters total. + assert len(params) <= 5, params + # Two positional dates: entity, from_date, to_date come first, positional. + assert params[:3] == ["entity", "from_date", "to_date"] + for name in ("entity", "from_date", "to_date"): + assert sig.parameters[name].kind in ( + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.POSITIONAL_ONLY, + ) + # label is the explicit, stable quickstart default. + assert sig.parameters["label"].default == "cli" + + +def test_weather_pairs_docstring_names_label_default_and_shows_desugaring() -> None: + import mostlyright.weather as w + + doc = inspect.getdoc(w.pairs) or "" + first_line = doc.splitlines()[0] if doc else "" + # FIRST line names the label="cli" default. + assert "cli" in first_line and "label" in first_line + # Body shows the desugaring: weather.days(...) -> label -> align(...). + assert "weather.days" in doc + assert "align" in doc + + +# --------------------------------------------------------------------------- +# Graduation is VISIBLE — an unsupported kwarg raises a targeted error. +# --------------------------------------------------------------------------- + + +def test_weather_pairs_unsupported_kwarg_raises_graduation_error() -> None: + import mostlyright.weather as w + + with pytest.raises(Exception) as excinfo: + w.pairs("KNYC", "2025-01-06", "2025-01-12", bogus_kwarg=1) + msg = str(excinfo.value) + # NOT a bare "unexpected keyword argument" TypeError — a targeted graduation error. + assert "capped quickstart" in msg + assert "bogus_kwarg" in msg + assert "align" in msg + assert "migration guide" in msg + + +# --------------------------------------------------------------------------- +# markets.kalshi.pairs — same two-positional-dates shape; dataset() is a shim. +# --------------------------------------------------------------------------- + + +def test_markets_kalshi_pairs_exists_two_positional_dates() -> None: + import mostlyright.markets.kalshi as k + + assert callable(k.pairs) + params = list(inspect.signature(k.pairs).parameters) + assert params[:3] == ["entity", "from_date", "to_date"] + + +def test_markets_kalshi_dataset_is_deprecation_shim_to_pairs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``kalshi.dataset(...)`` still works but warns + routes to ``pairs()``.""" + import mostlyright.markets.kalshi as k + + sentinel = object() + captured: dict[str, object] = {} + + def _fake_pairs(entity, from_date, to_date, **kw): + captured["entity"] = entity + return sentinel + + monkeypatch.setattr(k, "pairs", _fake_pairs) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + out = k.dataset("KXHIGHNY-25MAY26-T79", "2025-05-01", "2025-05-02") + + assert out is sentinel + assert captured.get("entity") == "KXHIGHNY-25MAY26-T79" + dep = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert any("pairs" in str(w.message) for w in dep) + + +def test_markets_polymarket_pairs_exists_two_positional_dates() -> None: + import mostlyright.markets.polymarket as p + + assert callable(p.pairs) + params = list(inspect.signature(p.pairs).parameters) + assert params[:3] == ["entity", "from_date", "to_date"] + + +def test_markets_polymarket_dataset_is_deprecation_shim_to_pairs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import mostlyright.markets.polymarket as p + + sentinel = object() + + def _fake_pairs(entity, from_date, to_date, **kw): + return sentinel + + monkeypatch.setattr(p, "pairs", _fake_pairs) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + out = p.dataset("KXHIGHLA", "2025-05-01", "2025-05-02") + + assert out is sentinel + dep = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert any("pairs" in str(w.message) for w in dep) + + +def test_pairs_obs_features_attach_non_nan(monkeypatch) -> None: + """Review-iter-2 CRITICAL regression: run ``pairs()`` to COMPLETION offline + and assert the ``obs_*`` feature columns actually attach (non-NaN). The + days()-built spine and the obs daily frames must share the normalized + station key ("NYC") — previously the spine carried "KNYC" while obs carried + "NYC", so the equality join matched zero rows and every observation feature + in the flagship quickstart was silently NaN.""" + import importlib + + import pandas as pd + + def _fake_dataset(*, station, from_date, to_date, **kw): + dates = pd.date_range(from_date, to_date, freq="D") + n = len(dates) + return pd.DataFrame( + { + "station": [station] * n, + "cli_high_f": [70.0 + i for i in range(n)], + "cli_low_f": [50.0 + i for i in range(n)], + "cli_report_type": ["IS"] * n, + }, + index=pd.DatetimeIndex(dates, name="date"), + ) + + research_mod = importlib.import_module("mostlyright.research") + monkeypatch.setattr(research_mod, "dataset", _fake_dataset) + + obs_mod = importlib.import_module("mostlyright.weather.obs") + monkeypatch.setattr( + obs_mod, + "_dispatch_strategy", + lambda *a, **k: [ + {"observed_at": "2025-01-01T18:00:00Z", "temp_f": 40.0}, + {"observed_at": "2025-01-02T18:00:00Z", "temp_f": 41.0}, + ], + ) + + from mostlyright.weather import pairs + + out = pairs("KNYC", "2025-01-01", "2025-01-02") + + assert "obs_high_f" in out.columns + assert out["obs_high_f"].notna().all() + assert list(out["station"].unique()) == ["NYC"] # normalized heritage key diff --git a/packages/weather/tests/test_weather_forecasts_source.py b/packages/weather/tests/test_weather_forecasts_source.py new file mode 100644 index 00000000..3f833bf8 --- /dev/null +++ b/packages/weather/tests/test_weather_forecasts_source.py @@ -0,0 +1,154 @@ +"""Phase 34 04 — RED/GREEN tests for ``weather.forecasts()`` (R-01). + +``weather.forecasts(entity, from_date=None, to_date=None, *, source='iem_mos', +model=None, models=None)`` is a REGISTERED wrap-and-register bitemporal source +that WRAPS ``dataset(include_forecast=True)`` (NO new fetcher). It is window- +optional (a dateless call returns a :class:`LazySourceSpec`, 34-03), absorbs the +four migrated forecast kwargs (``forecast_source``/``forecast_model``/ +``forecast_models``/``include_forecast``), and its ``fcst_*`` columns are +byte-consistent with today's ``dataset(include_forecast=True)`` join. +""" + +from __future__ import annotations + +import importlib + +import pandas as pd + + +def _fake_dataset( + *, + station, + from_date, + to_date, + label=None, + include_forecast=False, + forecast_source=None, + forecast_model=None, + forecast_models=None, + tz_override=None, + **kw, +): + """Byte-oracle stand-in for core ``dataset()`` — cli OR forecast frame. + + Stamps the NORMALIZED station code exactly like the real ``dataset()`` + (parity fixtures carry ``"NYC"``, never ``"KNYC"``) so the align join + against the days()-normalized spine behaves as in production. + """ + from mostlyright.snapshot import _station_code_normalized + + station = _station_code_normalized(station) + dates = pd.date_range(from_date, to_date, freq="D") + n = len(dates) + idx = pd.DatetimeIndex(dates, name="date") + if include_forecast: + return pd.DataFrame( + { + "station": [station] * n, + "fcst_high_f": [60.0 + i for i in range(n)], + "fcst_low_f": [40.0 + i for i in range(n)], + }, + index=idx, + ) + return pd.DataFrame( + { + "station": [station] * n, + "cli_high_f": [70.0 + i for i in range(n)], + "cli_low_f": [50.0 + i for i in range(n)], + "cli_report_type": ["IS"] * n, + }, + index=idx, + ) + + +def test_forecasts_windowed_returns_fcst_columns(monkeypatch) -> None: + """A windowed ``forecasts('KNYC', d1, d2)`` returns an eager frame carrying the + ``fcst_*`` columns (wrapping ``dataset(include_forecast=True)``).""" + research_mod = importlib.import_module("mostlyright.research") + monkeypatch.setattr(research_mod, "dataset", _fake_dataset) + + from mostlyright.weather import forecasts + + out = forecasts("KNYC", "2025-01-01", "2025-01-02") + + assert isinstance(out, pd.DataFrame) + assert "fcst_high_f" in out.columns + assert "fcst_low_f" in out.columns + assert list(out["fcst_high_f"]) == [60.0, 61.0] + + +def test_forecasts_dateless_returns_lazy_spec() -> None: + """A window-less ``forecasts('KNYC')`` returns a ``LazySourceSpec`` (34-03).""" + from mostlyright.align import LazySourceSpec + from mostlyright.weather import forecasts + + spec = forecasts("KNYC") + + assert isinstance(spec, LazySourceSpec) + assert spec.entity == "KNYC" + + +def test_forecasts_threads_source_model_models(monkeypatch) -> None: + """``source=``/``model=``/``models=`` thread onto the migrated core kwargs.""" + research_mod = importlib.import_module("mostlyright.research") + captured: dict = {} + + def _cap(**kw): + captured.clear() + captured.update(kw) + return _fake_dataset(**kw) + + monkeypatch.setattr(research_mod, "dataset", _cap) + + from mostlyright.weather import forecasts + + forecasts("KNYC", "2025-01-01", "2025-01-02", source="nwp", model="hrrr") + assert captured["include_forecast"] is True + assert captured["forecast_source"] == "nwp" + assert captured["forecast_model"] == "hrrr" + + forecasts("KNYC", "2025-01-01", "2025-01-02", models=["nbe", "nbs"]) + assert captured["forecast_models"] == ["nbe", "nbs"] + + +def test_forecasts_registered_as_a_source() -> None: + """Importing the surface registers ``weather.forecasts`` as a SOURCE (not a + label) in the registry catalog.""" + import mostlyright.weather.forecasts # noqa: F401 — triggers registration + from mostlyright import registry + + assert "weather.forecasts" in registry.entries() + + +def test_align_label_obs_forecasts_fcst_byte_consistent(monkeypatch) -> None: + """``align(weather.label.cli(...), weather.obs(...), weather.forecasts(...))`` + yields ``fcst_*`` columns byte-consistent with ``dataset(include_forecast=True)``.""" + research_mod = importlib.import_module("mostlyright.research") + monkeypatch.setattr(research_mod, "dataset", _fake_dataset) + + # Materialize obs offline (station mismatch is fine — this test asserts fcst_*). + obs_mod = importlib.import_module("mostlyright.weather.obs") + monkeypatch.setattr( + obs_mod, + "_dispatch_strategy", + lambda *a, **k: [ + {"observed_at": "2025-01-01T18:00:00Z", "temp_f": 55.0}, + {"observed_at": "2025-01-02T18:00:00Z", "temp_f": 56.0}, + ], + ) + + from mostlyright.align import align + from mostlyright.weather import forecasts, label, obs + + spine = label.cli("KNYC", "2025-01-01", "2025-01-02") + out = align(spine, obs("KNYC"), forecasts("KNYC")) + + assert "fcst_high_f" in out.columns + direct = research_mod.dataset( + station="KNYC", + from_date="2025-01-01", + to_date="2025-01-02", + include_forecast=True, + ) + assert list(out["fcst_high_f"]) == list(direct["fcst_high_f"]) + assert list(out["fcst_low_f"]) == list(direct["fcst_low_f"]) diff --git a/packages/weather/tests/test_weather_label_axis.py b/packages/weather/tests/test_weather_label_axis.py new file mode 100644 index 00000000..20706ede --- /dev/null +++ b/packages/weather/tests/test_weather_label_axis.py @@ -0,0 +1,203 @@ +"""Phase 34 04 — RED/GREEN tests for the weather LABEL/SPINE axis. + +``weather.days()`` (re-exported from ``weather/__init__.py`` — NOT +``weather.spine``) is the PUBLIC bare per-LST-settlement-day spine constructor: +two positional dates (inclusive ends), a single id OR a LIST (→ a LONG-FORMAT +panel byte-equal to the shipped ``dataset(stations=[...])`` loop). ``tz_override`` +is the orphan-kwarg home (it moves the settlement calendar). ``weather.label.cli`` +/ ``weather.label.daily_extremes`` are ``.label.``-registered spine FACTORIES that +build on ``weather.days()`` and attach y columns byte-identical to the shipped +recipes. ``weather.obs(qc=)`` is the qc orphan-kwarg home. +""" + +from __future__ import annotations + +import importlib + +import pandas as pd +import pytest + +# --------------------------------------------------------------------------- +# weather.days() — the public bare spine constructor +# --------------------------------------------------------------------------- + + +def test_days_is_reexported_from_the_weather_package_root() -> None: + """``weather.days`` resolves from the package root (the re-export), NOT via + ``weather.spine.days`` — the public surface pins the entry point (R-03).""" + import mostlyright.weather as weather + + assert hasattr(weather, "days") + assert "days" in weather.__all__ + + +def test_days_two_positional_dates_bare_spine_shape() -> None: + """``days('KNYC', from, to)`` — TWO POSITIONAL DATES — returns a bare spine + (entity=station, a per-settlement-day ``decision_time``, NO y columns).""" + from mostlyright.weather import days + + out = days("KNYC", "2025-01-01", "2025-01-03") + + assert isinstance(out, pd.DataFrame) + assert "station" in out.columns + assert "decision_time" in out.columns + # bare spine: no label/target columns + assert not [c for c in out.columns if c.startswith(("cli_", "daily_extremes_", "y_"))] + # inclusive ends: 2025-01-01, -02, -03 → 3 rows + assert len(out) == 3 + # The spine keys on the NORMALIZED registry code (v0.14.1 heritage: + # obs() frames and research()/pairs() outputs carry "NYC", not "KNYC" — + # review-iter-2, the equality join must match). + assert list(out["station"]) == ["NYC", "NYC", "NYC"] + + +def test_days_tz_override_moves_the_settlement_calendar() -> None: + """``tz_override`` (the orphan-kwarg home) changes the settlement calendar, so + the same day resolves a DIFFERENT ``decision_time`` instant.""" + from mostlyright.weather import days + + eastern = days("KNYC", "2025-01-01", "2025-01-01") + pacific = days("KNYC", "2025-01-01", "2025-01-01", tz_override="America/Los_Angeles") + + assert pd.Timestamp(eastern["decision_time"].iloc[0]) != pd.Timestamp( + pacific["decision_time"].iloc[0] + ) + + +def test_days_list_is_long_format_panel_byte_equal_to_the_loop() -> None: + """A LIST ``stations=`` mints per-station calendars → a LONG-FORMAT panel + (station column FIRST) byte-equal to the manual per-station ``days()`` loop, + with no cross-station bleed.""" + from mostlyright.weather import days + + panel = days(["KNYC", "KLGA"], "2025-01-01", "2025-01-02") + + # station column first + assert next(iter(panel.columns)) == "station" + # byte-equal to the concat of the per-station single calls (this IS that loop) + manual = pd.concat( + [days("KNYC", "2025-01-01", "2025-01-02"), days("KLGA", "2025-01-01", "2025-01-02")], + ignore_index=True, + ) + manual = manual[["station", *[c for c in manual.columns if c != "station"]]] + pd.testing.assert_frame_equal(panel.reset_index(drop=True), manual.reset_index(drop=True)) + # no cross-station bleed: each station keys its own rows + assert set(panel["station"]) == {"NYC", "LGA"} # normalized registry codes + assert len(panel) == 4 + + +# --------------------------------------------------------------------------- +# weather.label.cli / weather.label.daily_extremes — .label. spine factories +# --------------------------------------------------------------------------- + + +def _fake_cli_dataset(*, station, from_date, to_date, label=None, tz_override=None, **kw): + """A byte-oracle stand-in for ``dataset(label='cli')`` — the cli recipe output.""" + dates = pd.date_range(from_date, to_date, freq="D") + n = len(dates) + return pd.DataFrame( + { + "station": [station] * n, + "cli_high_f": [70.0 + i for i in range(n)], + "cli_low_f": [50.0 + i for i in range(n)], + "cli_report_type": ["IS"] * n, + }, + index=pd.DatetimeIndex(dates, name="date"), + ) + + +def test_label_cli_two_positional_dates_y_byte_identical_to_recipe(monkeypatch) -> None: + """``weather.label.cli(entity, from, to)`` — TWO POSITIONAL DATES — returns a + spine whose y columns are byte-identical to the shipped cli recipe output.""" + research_mod = importlib.import_module("mostlyright.research") + monkeypatch.setattr(research_mod, "dataset", _fake_cli_dataset) + + from mostlyright.weather import label + + out = label.cli("KNYC", "2025-01-01", "2025-01-02") + + assert next(iter(out.columns)) == "station" + for col in ("decision_time", "label_available_time", "cli_high_f", "cli_low_f"): + assert col in out.columns + # y byte-identical to the recipe output aligned by settlement day + assert list(out["cli_high_f"]) == [70.0, 71.0] + assert list(out["cli_low_f"]) == [50.0, 51.0] + + +def _fake_daily_extremes(station, from_date, to_date): + """Stand-in for ``mostlyright.international.daily_extremes`` rows.""" + return [ + { + "local_date": "2025-01-01", + "tmax_c": 1.0, + "tmin_c": -1.0, + "tmean_c": 0.0, + "n_obs": 24, + "source_tmax": "wu", + "source_tmin": "wu", + }, + { + "local_date": "2025-01-02", + "tmax_c": 2.0, + "tmin_c": -2.0, + "tmean_c": 0.0, + "n_obs": 24, + "source_tmax": "wu", + "source_tmin": "wu", + }, + ] + + +def test_label_daily_extremes_wraps_the_byte_stable_recipe(monkeypatch) -> None: + """``weather.label.daily_extremes(entity, from, to)`` wraps + ``compose_daily_extremes_label`` and attaches its native y columns.""" + intl = importlib.import_module("mostlyright.international") + monkeypatch.setattr(intl, "daily_extremes", _fake_daily_extremes) + + from mostlyright.weather import label + + out = label.daily_extremes("KNYC", "2025-01-01", "2025-01-02") + + assert "daily_extremes_tmax_c" in out.columns + assert "daily_extremes_tmin_c" in out.columns + assert list(out["daily_extremes_tmax_c"]) == [1.0, 2.0] + assert list(out["daily_extremes_tmin_c"]) == [-1.0, -2.0] + + +def test_label_dot_label_registration_invariant() -> None: + """Importing the label surface registers ``weather.label.cli`` under a + ``.label.`` id; registering that same id as a SOURCE raises ContractError.""" + import mostlyright.weather.label # noqa: F401 — triggers registration + from mostlyright import registry + from mostlyright.contracts import SourceContract + + assert "weather.label.cli" in registry.entries() + assert "weather.label.daily_extremes" in registry.entries() + + from mostlyright.core.exceptions import ContractError + + with pytest.raises(ContractError): + registry.source( + SourceContract( + id="weather.label.cli", + prefix="lbl_", + knowledge_time_col="kt", + pit_fidelity="reconstructed", + ) + ) + + +# --------------------------------------------------------------------------- +# weather.obs(qc=) — the qc orphan-kwarg home +# --------------------------------------------------------------------------- + + +def test_obs_accepts_the_qc_source_spec_option() -> None: + """``weather.obs(qc=True)`` is accepted as a source-spec option (qc's home) — + a dateless call still returns a lazy source spec.""" + from mostlyright.align import LazySourceSpec + from mostlyright.weather import obs + + spec = obs("KNYC", qc=True) + + assert isinstance(spec, LazySourceSpec) diff --git a/scripts/export_schemas.py b/scripts/export_schemas.py index 9b01ff53..23ba4727 100644 --- a/scripts/export_schemas.py +++ b/scripts/export_schemas.py @@ -65,6 +65,36 @@ / "earnings-calendar.json" ) +# --------------------------------------------------------------------------- +# Phase 34 07 — the per-domain catalog manifest TS discover() filters. +# +# The registration metadata (Plan 02 registry + the Plan 05 language-tagged +# {py, ts} usage record) IS the search index. The exporter emits the WEATHER +# catalog manifest from the live Python registry so it is the one-source-of-truth +# codegen (R-08) — hand-editing it drifts from the registry. It is a LAZY static +# JSON asset on the weather package's `./catalog` subpath, deliberately OUTSIDE +# the size-budgeted meta bundle (TS-BUNDLE-01); markets/econ/meta subpaths are a +# follow-up ticket line (R-25, PT-3401). +# --------------------------------------------------------------------------- + +#: Where the generated weather catalog manifest is committed (consumed by TS +#: discover() via the `@mostlyrightmd/weather/catalog` subpath export). +_CATALOG_TS_PATH: Path = _REPO_ROOT / "packages-ts" / "weather" / "catalog.json" + +#: Domains whose surface ships in a TypeScript package for 1.17. A source in one +#: of these domains is `availability: py,ts` in the manifest (the weather TS +#: package ships obs/climate/forecasts/live). CORS-only or not-yet-ported domains +#: keep their registered ``SourceContract.availability`` (partial parity is +#: official + documented; R-25 scopes the catalog subpath to weather for 1.17). +# NOTE: mirrors mostlyright.registry._catalog._TS_PORTED_DOMAINS (the record +# builder that emits the truthful usage.ts). Kept as a module-level literal so +# this script stays importable before `uv sync`; the byte-equality codegen test +# pins the two against each other via the committed manifest. +_TS_PORTED_DOMAINS: frozenset[str] = frozenset({"weather"}) + +#: The single domain the 1.17 catalog manifest is scoped to (R-25). +_CATALOG_DOMAIN: str = "weather" + # Add each packages//src to sys.path so we can `import mostlyright.*` # without requiring the workspace to be `uv sync`'d first. ``uv run`` / # ``uv sync`` users will already have these on ``sys.path``; the explicit @@ -595,6 +625,82 @@ def _build_earnings_calendar() -> _OutputFile: return _OutputFile(rel_path=rel, content=_dumps(payload).encode("utf-8")) +# --------------------------------------------------------------------------- +# Phase 34 07 — per-domain catalog manifest (TS discover() index) +# --------------------------------------------------------------------------- + + +def _ts_availability(base: str, domain: str, kind: str) -> str: + """Return the manifest availability for a registered entry in ``domain``. + + ONLY a ``kind == "source"`` entry whose domain ships a TS package + (``_TS_PORTED_DOMAINS``) is upgraded to ``"py,ts"`` — TS consumes weather + sources through ``dataset()``/``research()``. ``label`` entries (the + ``weather.label.*`` recipes) are Python-only on 1.17 (PT-3401) and keep + their registered ``SourceContract.availability``, as does every other + domain (partial parity stays explicit + documented, review-iter-1). + """ + if kind == "source" and domain in _TS_PORTED_DOMAINS: + return "py,ts" + return base + + +def _build_catalog_records() -> list[dict[str, Any]]: + """Build the weather catalog records from the LIVE Python registry (R-08). + + Importing ``mostlyright.weather`` triggers the import-time + ``registry.source()`` registrations; the records reuse the Plan 05 + ``record_from_contract`` schema (so the manifest carries the language-tagged + ``{py, ts}`` usage, R-20) and are sorted by id for byte-determinism. + """ + import warnings + + with warnings.catch_warnings(): + # The registry surface fires a one-shot ExperimentalFeatureWarning on the + # first import-time source registration; it is cosmetic for codegen. + warnings.simplefilter("ignore") + import mostlyright.weather + + # weather.label.* registers at SUBPACKAGE import time — the package + # __init__ does not pull it in, so without this import the manifest + # silently ships only weather.forecasts (review-iter-1). + import mostlyright.weather.label # noqa: F401 (label registration) + from mostlyright import _contributors as _contrib + from mostlyright.registry import entries + from mostlyright.registry._catalog import record_from_contract + + records: list[dict[str, Any]] = [] + for source_id, contract in entries().items(): + domain = source_id.split(".", 1)[0] + if domain != _CATALOG_DOMAIN: + continue + spec = _contrib._REGISTRY.get(source_id) + kind = spec.kind if spec is not None else "source" + record = record_from_contract(source_id, contract, kind) + record["availability"] = _ts_availability(contract.availability, domain, kind) + records.append(record) + records.sort(key=lambda r: r["id"]) + return records + + +def build_catalog_manifest_bytes() -> bytes: + """Build the weather catalog manifest as canonical bytes (deterministic). + + ``_dumps`` gives sorted keys + a trailing newline, so two runs are + byte-identical (verified by ``--check``). Written to + ``packages-ts/weather/catalog.json`` by :func:`main` (kept OUTSIDE the + ``schemas/`` tree + the schemas ``EXPORT_MANIFEST`` so the schema-drift diff + for ``schemas/`` is unaffected; the catalog's byte-stability is enforced by + the extended ``--check`` self-test). + """ + doc = { + "version": 1, + "domain": _CATALOG_DOMAIN, + "entries": _build_catalog_records(), + } + return _dumps(doc).encode("utf-8") + + # --------------------------------------------------------------------------- # Manifest assembly # --------------------------------------------------------------------------- @@ -680,8 +786,27 @@ def _mirror_earnings_calendar_to_package(files: list[_OutputFile]) -> None: return +def _write_catalog_manifest() -> None: + """Write the weather catalog manifest to ``packages-ts/weather/catalog.json``.""" + _CATALOG_TS_PATH.parent.mkdir(parents=True, exist_ok=True) + _CATALOG_TS_PATH.write_bytes(build_catalog_manifest_bytes()) + + def _check_determinism() -> int: """Build outputs twice in memory; assert byte-equality. Returns 0/1.""" + # The weather catalog manifest (Phase 34 07) rides the same determinism + # self-test: a second build MUST be byte-identical so the schema-drift gate + # regenerates it cleanly (R-08). + catalog_a = build_catalog_manifest_bytes() + catalog_b = build_catalog_manifest_bytes() + if catalog_a != catalog_b: + print( + "export_schemas --check FAILED: catalog manifest content mismatch " + f"({len(catalog_a)} vs {len(catalog_b)} bytes)", + file=sys.stderr, + ) + return 1 + run_a = build_all_outputs() run_b = build_all_outputs() if len(run_a) != len(run_b): @@ -706,7 +831,10 @@ def _check_determinism() -> int: file=sys.stderr, ) return 1 - print(f"export_schemas --check OK ({len(run_a)} files byte-identical across runs)") + print( + f"export_schemas --check OK ({len(run_a)} files + catalog manifest " + "byte-identical across runs)" + ) return 0 @@ -743,10 +871,20 @@ def main(argv: list[str] | None = None) -> int: # Only mirror the seed into the package when exporting to the canonical # repo-root schemas/ dir (the real codegen). A test/ad-hoc ``--out-dir`` run # must not touch the committed package-data tree. + catalog_written = False if args.out_dir.resolve() == _DEFAULT_OUT_DIR.resolve(): _mirror_earnings_calendar_to_package(files) + # The weather catalog manifest is a codegen artifact under packages-ts/, + # not the schemas/ tree — only emitted for the canonical default run so an + # ad-hoc/test ``--out-dir`` never rewrites the committed manifest. + _write_catalog_manifest() + catalog_written = True n_gated = sum(1 for f in files if f.gated) - print(f"export_schemas: wrote {len(files)} files to {args.out_dir} ({n_gated} gated stubs)") + suffix = " + weather catalog manifest" if catalog_written else "" + print( + f"export_schemas: wrote {len(files)} files to {args.out_dir} " + f"({n_gated} gated stubs){suffix}" + ) return 0 diff --git a/tests/fixtures/parity/conformance/README.md b/tests/fixtures/parity/conformance/README.md new file mode 100644 index 00000000..d36f04d8 --- /dev/null +++ b/tests/fixtures/parity/conformance/README.md @@ -0,0 +1,73 @@ +# Cross-SDK align() conformance fixtures (Phase 34 Plan 07) + +The `align()` as-of join engine is implemented (or will be implemented) twice — +once in Python (`packages/core/src/mostlyright/align.py`) and once in TypeScript +(deferred behind parity ticket **PT-3401**). This directory is the **single +source of truth** for the exact semantics both languages MUST agree on. + +**Do NOT duplicate these fixtures per language.** One JSON set, two consumers: + +- Python: `tests/test_align_conformance.py` drives the real pure join core + (`_collapse_revisions` / `_asof_join` / `_equality_join` / `align`) against + every fixture and asserts the expected output. This is the HARD gate. +- TypeScript: `packages-ts/meta/src/__tests__/alignConformance.test.ts` reads + the **identical** JSON files. The semantics that gate on the deferred async + `align()` are documented `it.skip`s referencing PT-3401; the fixture-loading / + structural assertions run green so the vectors are verifiable the moment the + TS aligner lands. + +## The four pinned knobs + +| Fixture | Knob under test | The invariant | +|---|---|---| +| `tie_break.json` | revision authority orthogonal to arrival + first-seen tie | a lower-authority revision that arrives LATER still loses to an earlier higher-authority row; at equal authority the first-seen row wins (STRICT `>` semantics, `revision_order=(("report_type_priority","desc"),)`, `revision_tie="first_seen"`) | +| `boundary.json` | boundary inclusivity (`<=` at the cutoff) | a source row at `knowledge_time == decision_time` IS joined (`allow_exact_matches`); a row strictly after the cutoff is dropped | +| `precision.json` | timestamp precision (ns-vs-ms boundary equality) | two timestamps equal at millisecond but differing at sub-millisecond resolve by the pinned nanosecond precision — the 500 ns-later row is strictly after the cutoff and is dropped | +| `nan_semantics.json` | NaN semantics | a spine row with no qualifying source row yields NaN feature columns; the row is PRESERVED, never dropped | + +## Fixture schema + +Each fixture is a single JSON object: + +```jsonc +{ + "knob": "boundary", // which pinned knob this vector exercises + "title": "…", // one-line human description + "description": "…", // longer prose (why the expected output holds) + "spec": { // kwargs for mostlyright.contracts.SourceContract + "id": "conf.boundary.src", + "prefix": "src_", // feature sources are namespaced under this + "entity_key": ["station"], // JSON list → tuple + "event_time_col": "", // "" ⇒ backward as-of join on knowledge_time + "knowledge_time_col": "knowledge_time", + "revision_order": [], // JSON list-of-pairs → tuple-of-tuples + "revision_tie": "first_seen", + "pit_fidelity": "exact" // "exact" (leakage-audited) | "reconstructed" (audit-exempt) + }, + "spine": [ // the spine frame (rows that must exist) + { "station": "S1", "decision_time": "2024-01-01T12:00:00Z" } + ], + "source_rows": [ // the source frame, in ARRIVAL ORDER + { "station": "S1", "knowledge_time": "2024-01-01T10:00:00Z", "value": 1 } + ], + "match_keys": ["station", "decision_time"], // columns that identify an output row + "compare_columns": ["src_value"], // output columns the assertion checks + "expected": [ // one row per spine row (matched by match_keys) + { "station": "S1", "decision_time": "2024-01-01T12:00:00Z", "src_value": 2 } + ] +} +``` + +Notes for consumers: + +- Timestamp strings are ISO-8601 UTC (`…Z`). The driver parses them tz-aware at + nanosecond precision — do NOT truncate to milliseconds. +- `null` in an `expected` `compare_columns` cell means "assert NaN / missing" + (the row is still present). +- For `event_time_col != ""` present on the spine, `align()` uses a materialized + equality join (the reconstructed-label path); otherwise it uses a backward + as-of join on `knowledge_time`. The join MODE is inferred from the spine shape + — `align()` never takes a join kwarg. +- A `pit_fidelity="exact"` feature source has its non-key columns namespaced + under `spec.prefix` (so `value` → `src_value`); a `pit_fidelity="reconstructed"` + label frame is byte-passed-through (column names preserved). diff --git a/tests/fixtures/parity/conformance/boundary.json b/tests/fixtures/parity/conformance/boundary.json new file mode 100644 index 00000000..de312d7c --- /dev/null +++ b/tests/fixtures/parity/conformance/boundary.json @@ -0,0 +1,29 @@ +{ + "knob": "boundary", + "title": "boundary inclusivity: knowledge_time == decision_time is joined (<=)", + "description": "A source row whose knowledge_time equals the spine decision_time IS selected (the <= convention via allow_exact_matches=True). A source row strictly after the cutoff is dropped: the 12:00 row is invisible to the 11:59 decision, which falls back to the 10:00 row.", + "spec": { + "id": "conf.boundary.src", + "prefix": "src_", + "entity_key": ["station"], + "event_time_col": "", + "knowledge_time_col": "knowledge_time", + "revision_order": [], + "revision_tie": "first_seen", + "pit_fidelity": "exact" + }, + "spine": [ + { "station": "S1", "decision_time": "2024-01-01T12:00:00Z" }, + { "station": "S1", "decision_time": "2024-01-01T11:59:00Z" } + ], + "source_rows": [ + { "station": "S1", "knowledge_time": "2024-01-01T10:00:00Z", "value": 1 }, + { "station": "S1", "knowledge_time": "2024-01-01T12:00:00Z", "value": 2 } + ], + "match_keys": ["station", "decision_time"], + "compare_columns": ["src_value"], + "expected": [ + { "station": "S1", "decision_time": "2024-01-01T12:00:00Z", "src_value": 2 }, + { "station": "S1", "decision_time": "2024-01-01T11:59:00Z", "src_value": 1 } + ] +} diff --git a/tests/fixtures/parity/conformance/nan_semantics.json b/tests/fixtures/parity/conformance/nan_semantics.json new file mode 100644 index 00000000..213d7c95 --- /dev/null +++ b/tests/fixtures/parity/conformance/nan_semantics.json @@ -0,0 +1,26 @@ +{ + "knob": "nan_semantics", + "title": "no qualifying source row yields NaN; the spine row is preserved", + "description": "The 09:00 decision has no source row at or before it (the earliest knowledge_time is 10:00), so its feature column is NaN and the row is PRESERVED (never dropped). The 11:00 decision picks up the 10:00 row (value 7).", + "spec": { + "id": "conf.nan.src", + "prefix": "src_", + "entity_key": ["station"], + "event_time_col": "", + "knowledge_time_col": "knowledge_time", + "revision_order": [], + "revision_tie": "first_seen", + "pit_fidelity": "exact" + }, + "spine": [ + { "station": "S1", "decision_time": "2024-01-01T09:00:00Z" }, + { "station": "S1", "decision_time": "2024-01-01T11:00:00Z" } + ], + "source_rows": [{ "station": "S1", "knowledge_time": "2024-01-01T10:00:00Z", "value": 7 }], + "match_keys": ["station", "decision_time"], + "compare_columns": ["src_value"], + "expected": [ + { "station": "S1", "decision_time": "2024-01-01T09:00:00Z", "src_value": null }, + { "station": "S1", "decision_time": "2024-01-01T11:00:00Z", "src_value": 7 } + ] +} diff --git a/tests/fixtures/parity/conformance/precision.json b/tests/fixtures/parity/conformance/precision.json new file mode 100644 index 00000000..aaea155b --- /dev/null +++ b/tests/fixtures/parity/conformance/precision.json @@ -0,0 +1,25 @@ +{ + "knob": "precision", + "title": "timestamp precision: ns governs boundary equality, not ms", + "description": "Two source rows share the same millisecond (…12:00:00.000) but differ at nanosecond resolution: one at …000000000 (== decision) and one at …000000500 (500 ns AFTER decision). Under the pinned nanosecond precision the 500 ns-later row is strictly after the cutoff and is dropped, so the …000000000 row (value 1) is selected. If precision were truncated to ms the two would tie and a backward as-of would incorrectly select the later value 2 — expected value 1 pins ns.", + "spec": { + "id": "conf.precision.src", + "prefix": "src_", + "entity_key": ["station"], + "event_time_col": "", + "knowledge_time_col": "knowledge_time", + "revision_order": [], + "revision_tie": "first_seen", + "pit_fidelity": "exact" + }, + "spine": [{ "station": "S1", "decision_time": "2024-01-01T12:00:00.000000000Z" }], + "source_rows": [ + { "station": "S1", "knowledge_time": "2024-01-01T12:00:00.000000000Z", "value": 1 }, + { "station": "S1", "knowledge_time": "2024-01-01T12:00:00.000000500Z", "value": 2 } + ], + "match_keys": ["station", "decision_time"], + "compare_columns": ["src_value"], + "expected": [ + { "station": "S1", "decision_time": "2024-01-01T12:00:00.000000000Z", "src_value": 1 } + ] +} diff --git a/tests/fixtures/parity/conformance/tie_break.json b/tests/fixtures/parity/conformance/tie_break.json new file mode 100644 index 00000000..f1cf7907 --- /dev/null +++ b/tests/fixtures/parity/conformance/tie_break.json @@ -0,0 +1,31 @@ +{ + "knob": "tie_break", + "title": "revision authority is orthogonal to arrival; equal authority is first-seen", + "description": "A 'correction' (report_type_priority 2.0) arrives at a LATER position than an earlier 'final' (report_type_priority 3.0) for the same (station, date). The final MUST win — authority is orthogonal to arrival. For 2024-01-06 two equal-authority (3.0) rows arrive; the first-seen row wins (STRICT > semantics, first-seen tie). This is the legacy climate dedup expressed as a conforming SourceContract, not a special case inside align().", + "spec": { + "id": "conf.tie_break.cli", + "prefix": "cli_", + "entity_key": ["station"], + "event_time_col": "date", + "knowledge_time_col": "", + "revision_order": [["report_type_priority", "desc"]], + "revision_tie": "first_seen", + "pit_fidelity": "reconstructed" + }, + "spine": [ + { "station": "KNYC", "date": "2024-01-05", "decision_time": "2024-01-06T00:00:00Z" }, + { "station": "KNYC", "date": "2024-01-06", "decision_time": "2024-01-07T00:00:00Z" } + ], + "source_rows": [ + { "station": "KNYC", "date": "2024-01-05", "report_type_priority": 3.0, "cli_high_f": 50 }, + { "station": "KNYC", "date": "2024-01-05", "report_type_priority": 2.0, "cli_high_f": 99 }, + { "station": "KNYC", "date": "2024-01-06", "report_type_priority": 3.0, "cli_high_f": 60 }, + { "station": "KNYC", "date": "2024-01-06", "report_type_priority": 3.0, "cli_high_f": 61 } + ], + "match_keys": ["station", "date"], + "compare_columns": ["cli_high_f"], + "expected": [ + { "station": "KNYC", "date": "2024-01-05", "cli_high_f": 50 }, + { "station": "KNYC", "date": "2024-01-06", "cli_high_f": 60 } + ] +} diff --git a/tests/test_align_conformance.py b/tests/test_align_conformance.py new file mode 100644 index 00000000..b1211cb0 --- /dev/null +++ b/tests/test_align_conformance.py @@ -0,0 +1,128 @@ +"""Python side of the shared cross-SDK align() conformance suite (Phase 34 07). + +Drives the PURE join core from Plan 03 — ``_collapse_revisions`` / +``_asof_join`` / ``_equality_join`` / ``align`` — against the shared JSON +fixtures under ``tests/fixtures/parity/conformance/``. The IDENTICAL fixtures are +read by ``packages-ts/meta/src/__tests__/alignConformance.test.ts`` so the two +language implementations are pinned to one truth (tie-break authority, boundary +inclusivity ``<=``, nanosecond timestamp precision, NaN semantics). + +This is the HARD gate: a divergence between align()'s behavior and the pinned +expected output fails the suite. + +RED→GREEN: ``_align_conformance_result`` is the driver wired in the GREEN commit; +the RED commit ships it as a stub that drops the joined rows so the semantic +assertions fail before the driver is complete. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pandas as pd +import pytest +from mostlyright.align import align +from mostlyright.contracts import SourceContract + +CONFORMANCE_DIR = Path(__file__).parent / "fixtures" / "parity" / "conformance" + +#: The four pinned knobs, one JSON fixture each. +FIXTURE_NAMES = ("tie_break", "boundary", "precision", "nan_semantics") + + +def _load_fixture(name: str) -> dict[str, Any]: + return json.loads((CONFORMANCE_DIR / f"{name}.json").read_text(encoding="utf-8")) + + +def _build_spec(spec_dict: dict[str, Any]) -> SourceContract: + """Turn the JSON ``spec`` block into a :class:`SourceContract`. + + JSON lists become the tuples the frozen dataclass expects (``entity_key``, + ``revision_order``). + """ + kwargs = dict(spec_dict) + kwargs["entity_key"] = tuple(kwargs.get("entity_key", ["station"])) + kwargs["revision_order"] = tuple(tuple(pair) for pair in kwargs.get("revision_order", [])) + return SourceContract(**kwargs) + + +def _to_utc(series: pd.Series) -> pd.Series: + """Parse ISO-8601 UTC strings tz-aware at nanosecond precision.""" + return pd.to_datetime(series, utc=True) + + +def _align_conformance_result(fixture: dict[str, Any]) -> pd.DataFrame: + """Drive the pure join core for one fixture and return the aligned frame. + + Builds the spine + source frames (timestamps parsed tz-aware ns), then calls + the frozen ``align(spine, (frame, spec))`` operator — no policy kwargs. The + join MODE (equality vs backward as-of) is inferred by ``align`` from the spine + shape, exactly as production code composes it. + """ + spec = _build_spec(fixture["spec"]) + + spine = pd.DataFrame(fixture["spine"]) + if "decision_time" in spine.columns: + spine["decision_time"] = _to_utc(spine["decision_time"]) + + frame = pd.DataFrame(fixture["source_rows"]) + kt = spec.knowledge_time_col + if kt and kt in frame.columns: + frame[kt] = _to_utc(frame[kt]) + + return align(spine, (frame, spec)) + + +def _match_row(result: pd.DataFrame, keys: list[str], key_values: dict[str, Any]) -> pd.Series: + """Return the single result row matching ``key_values`` on ``keys``.""" + mask = pd.Series(True, index=result.index) + for key in keys: + col = result[key] + want = key_values[key] + if pd.api.types.is_datetime64_any_dtype(col): + want = pd.Timestamp(want) + if want.tzinfo is None: + want = want.tz_localize("UTC") + mask &= col == want + matched = result[mask] + assert len(matched) == 1, f"expected exactly one row for {key_values!r}, got {len(matched)}" + return matched.iloc[0] + + +@pytest.mark.parametrize("name", FIXTURE_NAMES) +def test_align_conformance(name: str) -> None: + """align() reproduces the pinned expected output for every conformance vector.""" + fixture = _load_fixture(name) + result = _align_conformance_result(fixture) + + match_keys = fixture["match_keys"] + compare_columns = fixture["compare_columns"] + + # Every spine row must survive (rows are preserved, never dropped). + assert len(result) == len(fixture["spine"]) + + for expected in fixture["expected"]: + row = _match_row(result, match_keys, expected) + for col in compare_columns: + want = expected[col] + got = row[col] + if want is None: + assert pd.isna(got), f"{name}: {col} expected NaN, got {got!r}" + elif isinstance(want, (int, float)): + assert not pd.isna(got), f"{name}: {col} expected {want}, got NaN" + assert float(got) == float(want), f"{name}: {col} expected {want}, got {got!r}" + else: + assert got == want, f"{name}: {col} expected {want!r}, got {got!r}" + + +def test_all_four_knobs_present() -> None: + """The four pinned-knob fixtures + README are the single source of truth.""" + for name in FIXTURE_NAMES: + assert (CONFORMANCE_DIR / f"{name}.json").exists(), f"missing fixture {name}.json" + assert (CONFORMANCE_DIR / "README.md").exists() + # tie_break pins the revision-collapse verdict (report_type_priority authority). + tie = _load_fixture("tie_break") + assert tie["spec"]["revision_order"] == [["report_type_priority", "desc"]] + assert tie["spec"]["revision_tie"] == "first_seen" diff --git a/tests/test_export_schemas.py b/tests/test_export_schemas.py index 585f0ea4..616704df 100644 --- a/tests/test_export_schemas.py +++ b/tests/test_export_schemas.py @@ -334,3 +334,66 @@ def test_committed_package_data_seed_matches_codegen(tmp_path: Path) -> None: "committed package-data earnings-calendar.json drifted from the codegen " "output — re-run scripts/export_schemas.py to re-mirror it." ) + + +# --------------------------------------------------------------------------- +# Review-iter-1: the TS catalog manifest (packages-ts/weather/catalog.json) +# must (a) be byte-identical to what the live registry emits — the schema-drift +# CI job diffs the committed file, and this test enforces the same contract in +# the fast suite; (b) actually carry the weather.label.* registrations (they +# register at SUBPACKAGE import time and previously never reached the +# manifest); (c) stay TRUTHFUL for TS users: label recipes are Python-only on +# 1.17 (PT-3401) and usage.ts never names a Python-only call. +# --------------------------------------------------------------------------- + + +def _catalog_entries() -> list[dict]: + manifest = json.loads( + (_REPO_ROOT / "packages-ts" / "weather" / "catalog.json").read_text(encoding="utf-8") + ) + return manifest["entries"] + + +def test_committed_catalog_manifest_matches_registry_emission() -> None: + """The committed manifest is byte-identical to the live-registry emission + (the enforcement half of one-source-of-truth codegen, R-08).""" + exporter = _import_exporter() + + emitted = exporter.build_catalog_manifest_bytes() + committed = (_REPO_ROOT / "packages-ts" / "weather" / "catalog.json").read_bytes() + + assert emitted == committed, ( + "packages-ts/weather/catalog.json is stale — run " + "'uv run python scripts/export_schemas.py' and commit the result" + ) + + +def test_catalog_manifest_carries_label_registrations() -> None: + """weather.label.* rows reach the manifest (they register at subpackage + import time — the exporter must import mostlyright.weather.label).""" + ids = {entry["id"] for entry in _catalog_entries()} + + assert "weather.forecasts" in ids + assert "weather.label.cli" in ids + assert "weather.label.daily_extremes" in ids + + +def test_catalog_manifest_availability_and_usage_are_truthful_for_ts() -> None: + """Source rows in the TS-ported weather domain are ``py,ts`` with a real TS + snippet; label rows stay Python-only (``py``) with ``ts: ""`` so TS + discover() falls back to the note/docs pointer (R-20, PT-3401).""" + by_id = {entry["id"]: entry for entry in _catalog_entries()} + + forecasts = by_id["weather.forecasts"] + assert forecasts["availability"] == "py,ts" + assert "dataset(" in forecasts["usage"]["ts"] + + for label_id in ("weather.label.cli", "weather.label.daily_extremes"): + label = by_id[label_id] + assert label["availability"] == "py" + assert label["usage"]["ts"] == "" + + for entry in by_id.values(): + ts_usage = entry["usage"]["ts"] + assert "pairs(" not in ts_usage + assert "align(" not in ts_usage diff --git a/tests/test_parity.py b/tests/test_parity.py index 0229dec4..a8041d05 100644 --- a/tests/test_parity.py +++ b/tests/test_parity.py @@ -249,10 +249,12 @@ def test_dataset_is_research_alias(monkeypatch: pytest.MonkeyPatch) -> None: research_mod = sys.modules["mostlyright.research"] - # They are NO LONGER one object (the split), but both must be exported. + # They are NO LONGER one object (the split). Phase 34 06 (D-34.20) DROPS both + # from ``__all__`` (root = machinery + domains only), but they stay importable + # via deprecated shims — asserted in test_dataset_alias / test_shims. assert mostlyright.dataset is not mostlyright.research - assert "dataset" in mostlyright.__all__ - assert "research" in mostlyright.__all__ + assert "dataset" not in mostlyright.__all__ + assert "research" not in mostlyright.__all__ # Capture what each wrapper forwards to the shared body. Both must forward # identical (station, from_date, to_date) + identical kwargs EXCEPT the @@ -319,23 +321,29 @@ def _boom(_station: object, **_kwargs: object) -> object: monkeypatch.setattr(research_mod, "_resolve_station", _boom) -def test_bare_dataset_emits_d22_future_warning(monkeypatch: pytest.MonkeyPatch) -> None: - """Phase 32 (32-02 / D-22): ``dataset()`` WITHOUT an explicit label emits the - EXACT D-22 bridge ``FutureWarning`` once. Network-free (halts before fetch). +def test_bare_dataset_emits_no_d22_future_warning(monkeypatch: pytest.MonkeyPatch) -> None: + """Phase 34 06 (D-34.22): the D-22 label-default FLIP is CANCELLED — ``dataset()`` + WITHOUT an explicit label no longer emits a ``FutureWarning``. Network-free + (halts before fetch). - The message text is pinned VERBATIM to the plan-of-record copy so a wording - drift is caught by the fast suite. + The defect was a default that would FLIP ("cli" -> None) at 2.0 behind a + FutureWarning; removing the flip removes the warning. The bridge constant is + gone from ``mostlyright.research`` entirely. """ - from mostlyright.research import _D22_BRIDGE_WARNING + import importlib + import warnings + + # Resolve the module via importlib (the ``mostlyright.research`` attribute is + # intercepted by the Phase-34-06 deprecation shim and returns the function). + _research_mod = importlib.import_module("mostlyright.research") + assert not hasattr(_research_mod, "_D22_BRIDGE_WARNING") _halt_after_label(monkeypatch) - with pytest.warns(FutureWarning) as record, pytest.raises(_StopBeforeFetch): + with warnings.catch_warnings(record=True) as record, pytest.raises(_StopBeforeFetch): + warnings.simplefilter("always") mostlyright.dataset("KNYC", "2025-01-06", "2025-01-12") d22 = [w for w in record if issubclass(w.category, FutureWarning)] - assert len(d22) == 1, f"expected exactly one D-22 FutureWarning, got {len(d22)}" - assert str(d22[0].message) == _D22_BRIDGE_WARNING - # Exact plan-of-record wording lock (belt-and-suspenders vs the constant). - assert "dataset() label default changes from 'cli' to None in 2.0" in str(d22[0].message) + assert d22 == [], f"D-22 FutureWarning should be gone; got {[str(w.message) for w in d22]}" def test_bare_research_does_not_warn(monkeypatch: pytest.MonkeyPatch) -> None: @@ -367,3 +375,98 @@ def test_explicit_label_cli_does_not_warn(monkeypatch: pytest.MonkeyPatch) -> No warnings.simplefilter("error", FutureWarning) with pytest.raises(_StopBeforeFetch): mostlyright.dataset("KNYC", "2025-01-06", "2025-01-12", label="cli") + + +# --------------------------------------------------------------------------- +# Phase 34 01 — align-based parity spike (the falsifier). +# +# The 5 live fixtures above ALREADY flow through the new ``align()`` operator: +# ``research()``'s CLI-label composition is re-plumbed onto +# ``research._compose_cli_by_date`` which routes the climate rows through +# ``mr.align(spine, *sources)`` (collapse via ``revision_order`` + equality +# join). The Rung-3 ``atol=PARITY_ATOL`` (1e-12) gate above is therefore the +# byte-level backstop of the spike. The two network-free tests below pin the +# spike's load-bearing claims directly (no live fetch): +# +# 1. Path A: the CLI label composed THROUGH align selects the authority +# ``final`` over a later ``correction`` (revision authority ⟂ arrival). +# 2. R-17: a forced two-source leak through ``align()`` raises a +# ``LeakageError`` whose text carries the source id + cutoff + fix. +# --------------------------------------------------------------------------- + + +def test_spike_cli_label_through_align_selects_final_over_later_correction() -> None: + """Path A (network-free): the CLI settlement label composed THROUGH + ``align()`` selects the authority ``final`` (priority 3.0) over a later + ``correction`` (priority 2.0) on the same (station, date) — proving the + bitemporal source contract expresses the legacy climate dedup with NO + special case inside ``align()``.""" + from mostlyright.research import _compose_cli_by_date + + raw_climate = [ + { + "station_code": "KNYC", + "observation_date": "2025-01-06", + "high_temp_f": 41, + "low_temp_f": 30, + "report_type": "final", + "report_type_priority": 3.0, + }, + { + "station_code": "KNYC", + "observation_date": "2025-01-06", + "high_temp_f": 99, + "low_temp_f": 88, + "report_type": "correction", + "report_type_priority": 2.0, + }, + ] + by_date = _compose_cli_by_date(raw_climate) + + assert set(by_date) == {"2025-01-06"} + winner = by_date["2025-01-06"] + assert winner["high_temp_f"] == 41 + assert winner["report_type"] == "final" + + +def test_spike_align_forced_leak_error_copy() -> None: + """R-17 (network-free): a forced two-source leak through ``align()`` raises a + ``LeakageError`` whose text carries the offending source id, the cutoff + (``decision_time``), and a concrete fix — the same error-copy contract the + live spike path asserts.""" + from mostlyright.align import align + from mostlyright.contracts import SourceContract + from mostlyright.core.exceptions import LeakageError + + spine = pd.DataFrame( + { + "station": ["A"], + "event_date": ["2025-01-02"], + "decision_time": pd.to_datetime(["2025-01-02T00:00:00Z"], utc=True), + } + ) + leaky_spec = SourceContract( + id="leaky_src", + prefix="leaky_", + entity_key=("station",), + event_time_col="event_date", + knowledge_time_col="leaky_kt", + pit_fidelity="exact", + ) + leaky_frame = pd.DataFrame( + { + "station": ["A"], + "event_date": ["2025-01-02"], + "leaky_kt": pd.to_datetime(["2025-01-05T00:00:00Z"], utc=True), + "leaky_value": [7], + } + ) + + with pytest.raises(LeakageError) as excinfo: + align(spine, (leaky_frame, leaky_spec)) + + assert excinfo.value.source == "leaky_src" + msg = str(excinfo.value) + assert "leaky_src" in msg + assert "2025-01-02" in msg + assert "knowledge_time" in msg diff --git a/tests/weather/test_obs_surface.py b/tests/weather/test_obs_surface.py index 5444f578..1b1a960f 100644 --- a/tests/weather/test_obs_surface.py +++ b/tests/weather/test_obs_surface.py @@ -34,8 +34,12 @@ def test_obs_signature_has_required_kwonly_params(): params = sig.parameters assert "station" in params - assert "start" in params - assert "end" in params + # R-02: window params renamed start/end -> from_date/to_date and made optional + # (default None) so a dateless call returns a lazy source spec. + assert "from_date" in params + assert "to_date" in params + assert params["from_date"].default is None + assert params["to_date"].default is None for name in ("source", "strategy", "as_dataframe", "backend", "return_type"): assert params[name].kind == inspect.Parameter.KEYWORD_ONLY, f"{name} must be keyword-only" diff --git a/uv.lock b/uv.lock index 25a26c8c..c903dfd1 100644 --- a/uv.lock +++ b/uv.lock @@ -1629,7 +1629,7 @@ wheels = [ [[package]] name = "mostlyrightmd" -version = "1.16.0" +version = "1.17.0" source = { editable = "packages/core" } dependencies = [ { name = "httpx" }, @@ -1673,7 +1673,7 @@ provides-extras = ["parquet", "research", "polars"] [[package]] name = "mostlyrightmd-econ" -version = "1.16.0" +version = "1.17.0" source = { editable = "packages/econ" } dependencies = [ { name = "filelock" }, @@ -1703,7 +1703,7 @@ provides-extras = ["pandas"] [[package]] name = "mostlyrightmd-markets" -version = "1.16.0" +version = "1.17.0" source = { editable = "packages/markets" } dependencies = [ { name = "httpx" }, @@ -1759,7 +1759,7 @@ provides-extras = ["parquet", "polymarket", "trades", "earnings", "polars"] [[package]] name = "mostlyrightmd-weather" -version = "1.16.0" +version = "1.17.0" source = { editable = "packages/weather" } dependencies = [ { name = "filelock" },