Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,44 @@ All notable changes to `mostlyright`. The format follows [Keep a Changelog](http

## [Unreleased]

## [1.12.1] — 2026-07-08 — #107 follow-ups: cross-SDK consistency + version metadata

Patch release, dual-SDK. Resolves the P3 follow-ups from the 1.12.0 review
loop (#107). No behavior change on any default path.

### Fixed

- **`__version__` now derived from dist metadata** in all three Python
packages via `importlib.metadata` (was hardcoded and stale: core/weather
reported `0.1.0rc1`, markets `0.0.1`, while dists shipped 1.12.x). Falls
back to `0.0.0+unknown` in metadata-less source trees. `DataVersion`
tokens computed via `for_research()` without an explicit `sdk_version`
now reflect the true SDK version (opt-in surface; not wired into
`research()`, no cache/settlement impact).
- **Cross-SDK unknown-source error text unified** on the `research()`
surface: both SDKs now emit `research(): source must be one of [...]`,
exact-message-pinned in both test suites. `researchBySource`'s
`Mode 2 source must be one of` text is unchanged.
- **TS `sources: null` honors the Phase 21 null-as-absent wire contract**
(was throwing the v0.3 deferred error for a JSON-round-tripped Python
`sources=None`); removed a dead TS mutual-exclusion check whose only
reachable firing was that same wire-contract bug, and the mirrored
unreachable Python `ValueError` block.
- **Pinned-source provenance threads explicitly through the wrapper path**
(`wrap_result(source=...)`, mirroring `mode2.research_by_source`) —
defense-in-depth; unpinned wrapper calls byte-identical.

### Added

- TS public barrel now re-exports `RESEARCH_SOURCE_ALIASES`,
`RESEARCH_OBSERVATION_SOURCES`, `isResearchObservationSource`.
- Coverage: `research(source=)` under `as_dataframe=False`, polars wrapper
(skip-guarded), empty-after-pin attrs; TS `sources: null` regression;
barrel surface test.
- Weather TS bundle growth root-caused (hosted-shim root-barrel re-export,
a tree-shaking blocker — not data growth); shrink deferred to its own PR
since it contracts the root-barrel API (#107).

## [1.12.0] — 2026-07-08 — research(source=) pin + Mode 2 window fix

Minor release, dual-SDK (PyPI 1.12.0 + npm 1.12.0 in lockstep).
Expand Down
2 changes: 1 addition & 1 deletion packages-ts/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mostlyrightmd/core",
"version": "1.12.0",
"version": "1.12.1",
"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",
Expand Down
2 changes: 1 addition & 1 deletion packages-ts/markets/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mostlyrightmd/markets",
"version": "1.12.0",
"version": "1.12.1",
"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",
Expand Down
2 changes: 1 addition & 1 deletion packages-ts/meta/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mostlyright",
"version": "1.12.0",
"version": "1.12.1",
"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",
Expand Down
10 changes: 10 additions & 0 deletions packages-ts/meta/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,21 @@ export { research, type ResearchOptions, type PairsRow } from "./research.js";
// package alongside research() — the dispatch needs the @mostlyrightmd/weather
// Observation type and assertSourceIdentity consumes it structurally;
// @mostlyrightmd/core must NOT depend on weather (cycle).
//
// Also re-exports the WIDENED `research({ source })` filter vocabulary
// (RESEARCH_SOURCE_ALIASES / RESEARCH_OBSERVATION_SOURCES /
// isResearchObservationSource) — the Python-parity superset that
// `research()`'s single-source pin validates against — so callers can
// introspect the accepted names without a deep import, mirroring the
// narrow-dispatch siblings (SOURCE_ALIASES / isMode2Source).
export {
MODE2_SOURCES,
RESEARCH_OBSERVATION_SOURCES,
RESEARCH_SOURCE_ALIASES,
SOURCE_ALIASES,
assertSourceIdentity,
isMode2Source,
isResearchObservationSource,
researchBySource,
type Mode2Source,
type ResearchBySourceOptions,
Expand Down
4 changes: 4 additions & 0 deletions packages-ts/meta/src/mode2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,10 @@ export async function researchBySource(
// outside the caller's window; per-source Mode 2 callers expect rows
// strictly inside [fromDate, toDate]. Python mode2.py parity.
rows = parsed.filter((r) => {
// `observed_at` is a guaranteed non-empty ISO string on Observation,
// so `.slice(0, 10)` is safe without a runtime guard (same in the
// IEM/GHCNh branches below). Python's `_in_query_window` must drop
// falsy `observed_at` defensively; TS leans on the schema type.
const d = r.observed_at.slice(0, 10);
return d >= fromDate && d <= toDate;
});
Expand Down
35 changes: 26 additions & 9 deletions packages-ts/meta/src/research.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,15 +869,27 @@ export async function research(
if (hasContracts) names.push("contracts");
throw new Error(`research(): selectors are mutually exclusive; got ${JSON.stringify(names)}`);
}
if (opts.sources !== undefined && opts.source != null) {
throw new Error("research(): sources and source are mutually exclusive");
}
// NOTE: the `sources=`/`source=` mutual-exclusion guard that used to live
// here was DEAD and is removed (mirrors the Python parallel cleanup that
// drops the duplicate `sources is not None and source is not None`
// ValueError in research.py). `validateResearchKwargs()` above (~line 822)
// already throws a TypeError for the both-provided combo with nullish
// (`!= null`) presence semantics BEFORE this point — proven by
// research.kwargs.test.ts ("raises TypeError on sources + source
// together"). The old check used `sources !== undefined`, so its ONLY
// reachable firing was `sources: null` + a real `source` — which the wire
// contract treats as `sources` ABSENT, so that firing was itself a bug.
//
// `sources` (plural) — the Mode 1 multi-source SUBSET selector — is
// validated in Phase 10 v0.2 but its dedupe-within-subset wiring lands in
// v0.3; passing it throws. NOTE: `source` (singular) is now FUNCTIONAL
// (single-source pin, wired below) — only the multi-source subset remains
// deferred.
if (opts.sources !== undefined) {
// deferred. Presence is nullish (`!= null`), NOT `!== undefined`: per the
// Phase 21 wire contract (research.types.ts `present()` / Python
// `sources is not None`), a JSON-round-tripped Python `sources=None`
// arrives as `null` and must behave as ABSENT — `!== undefined` would
// wrongly throw the v0.3-deferred error for it.
if (opts.sources != null) {
throw new Error(
"research(): the `sources` multi-source subset selector is validated in " +
"Phase 10 v0.2 but its dedupe-within-subset wiring lands in v0.3. For " +
Expand All @@ -887,15 +899,20 @@ export async function research(
}
// `source` (singular) — Mode 2 single-source pin. Validate against the
// widened Mode 2 vocabulary BEFORE any network fetch so invalid input
// never burns API quota. Mirrors Python `research_by_source`'s ValueError
// guard (packages/core/src/mostlyright/mode2.py:112-115); the accepted
// names are byte-identical across SDKs (RESEARCH_OBSERVATION_SOURCES).
// never burns API quota. Mirrors Python `research()`'s own source guard
// (packages/core/src/mostlyright/research.py:1750-1754): the canonical
// `research(): source must be one of ...` text and the accepted names
// (RESEARCH_OBSERVATION_SOURCES) are byte-identical across SDKs — each SDK
// pins its native quote style via its own stringify (JSON.stringify here,
// Python `repr` there). This is distinct from `researchBySource`'s
// narrower "Mode 2 source must be one of ..." guard (mode2.ts), which
// stays lockstep and is intentionally left untouched.
// Presence is nullish (`!= null`), NOT `!== undefined`: per the Phase 21
// wire contract (research.types.ts `present()`), a JSON-round-tripped
// Python `source=None` arrives as `null` and must behave as ABSENT.
if (opts.source != null && !isResearchObservationSource(opts.source)) {
throw new Error(
`Mode 2 source must be one of ${JSON.stringify(
`research(): source must be one of ${JSON.stringify(
[...RESEARCH_OBSERVATION_SOURCES].sort(),
)}; got ${JSON.stringify(opts.source)}`,
);
Expand Down
6 changes: 5 additions & 1 deletion packages-ts/meta/tests/compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,11 @@ describe("research() Phase 10 signature validation", () => {
// covered in research.test.ts.
await expect(
research("KNYC", "2025-01-06", "2025-01-12", { source: "not-a-real-source" }),
).rejects.toThrow(/Mode 2 source must be one of/);
).rejects.toThrow(
// Item 1 (v1.12.1): canonical cross-SDK `research(): source must be one
// of ...` text (each SDK pins its native quote style — JSON here).
'research(): source must be one of ["awc","awc.live","ghcnh","ghcnh.archive","iem","iem.archive","iem.live"]; got "not-a-real-source"',
);
});

it("stationOverride requires contract", async () => {
Expand Down
19 changes: 19 additions & 0 deletions packages-ts/meta/tests/mode2.barrel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ import { describe, expect, it } from "vitest";
import {
MODE2_SOURCES,
type Mode2Source,
RESEARCH_OBSERVATION_SOURCES,
RESEARCH_SOURCE_ALIASES,
SOURCE_ALIASES,
type SourceMismatchRole,
assertSourceIdentity,
isMode2Source,
isResearchObservationSource,
researchBySource,
} from "../src/index.js";

Expand Down Expand Up @@ -45,6 +48,22 @@ describe("meta barrel — TS-W4 Wave 1 Mode 2 surface", () => {
expect(isMode2Source("iem")).toBe(false);
});

it("re-exports the widened research({ source }) filter vocabulary (Item 2, v1.12.1)", () => {
// RESEARCH_* is the Python-parity SUPERSET that research()'s single-source
// pin validates against — 7 names = the 4 dotted forms + 3 bare tags.
expect(RESEARCH_OBSERVATION_SOURCES.size).toBe(7);
expect(RESEARCH_SOURCE_ALIASES.size).toBe(7);
// Bare parser tags are accepted here but NOT by isMode2Source (narrower).
expect(isResearchObservationSource("iem")).toBe(true);
expect(isMode2Source("iem")).toBe(false);
// Dotted forms accepted by both.
expect(isResearchObservationSource("iem.archive")).toBe(true);
// Unknown rejected.
expect(isResearchObservationSource("banana")).toBe(false);
// Alias table carries the bare-tag → alias-set rows.
expect(RESEARCH_SOURCE_ALIASES.get("awc")?.has("awc.live")).toBe(true);
});

it("Mode2Source + SourceMismatchRole types are accessible from the barrel", () => {
// Type-level check — assignments compile iff the type re-exports are wired.
const canon: Mode2Source = "iem.archive";
Expand Down
27 changes: 26 additions & 1 deletion packages-ts/meta/tests/research.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,11 @@ describe("research() — Mode 2 single-source pin (opts.source)", () => {
const fetchSpy = installMixedSourceMock();
await expect(
research("NYC", PIN_DAY, PIN_DAY, { source: "banana", now: PIN_NOW, cache: null }),
).rejects.toThrow(/Mode 2 source must be one of/);
).rejects.toThrow(
// Item 1 (v1.12.1): canonical cross-SDK text — `research(): source must
// be one of ...` (NOT the narrower researchBySource "Mode 2 source ...").
'research(): source must be one of ["awc","awc.live","ghcnh","ghcnh.archive","iem","iem.archive","iem.live"]; got "banana"',
);
expect(fetchSpy).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -547,4 +551,25 @@ describe("research() — Mode 2 single-source pin (opts.source)", () => {
expect(row?.obs_low_f).toBe(68);
expect(row?.obs_mean_f).toBe(80.5);
});

it("sources: null (JSON round-trip of Python None) behaves as ABSENT — full merge, no throw", async () => {
// Item 4 (v1.12.1): the Phase 21 wire contract (research.types.ts
// `present()` / Python `sources is not None`) treats a JSON-round-tripped
// Python `sources=None` (arriving as `null`) as not-provided. The
// deferred-`sources` guard previously used `!== undefined`, which threw
// the v0.3 NotImplemented-style error for `sources: null`; `!= null` lets
// the full merge proceed. Mirrors the `source: null` guard above.
installMixedSourceMock();
const rows = await research("NYC", PIN_DAY, PIN_DAY, {
sources: null as unknown as string[],
now: PIN_NOW,
cache: null,
});
expect(rows).toHaveLength(1);
const row = rows[0];
expect(row?.obs_count).toBe(4);
expect(row?.obs_high_f).toBe(104);
expect(row?.obs_low_f).toBe(68);
expect(row?.obs_mean_f).toBe(80.5);
});
});
2 changes: 1 addition & 1 deletion packages-ts/weather/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mostlyrightmd/weather",
"version": "1.12.0",
"version": "1.12.1",
"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",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "mostlyrightmd"
version = "1.12.0"
version = "1.12.1"
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"
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/mostlyright/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@
# Split-distribution namespace: extend __path__ to discover sibling packages' contributions.
__path__ = __import__("pkgutil").extend_path(__path__, __name__)

__version__ = "0.1.0rc1"
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _dist_version

try:
__version__ = _dist_version("mostlyrightmd")
except PackageNotFoundError: # editable/source tree without installed dist metadata
__version__ = "0.0.0+unknown"

from mostlyright.discover import discover
from mostlyright.research import research
Expand Down
21 changes: 12 additions & 9 deletions packages/core/src/mostlyright/research.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@

from __future__ import annotations

import contextlib
import json
import logging
from datetime import UTC, datetime, timedelta
Expand Down Expand Up @@ -1718,10 +1719,6 @@ def research(
]
raise ValueError(f"research(): selectors are mutually exclusive; got {provided!r}")

# `sources=` (plural) vs `source=` (singular) are mutually exclusive.
if sources is not None and source is not None:
raise ValueError("research(): sources= and source= are mutually exclusive")

# `sources=` (plural, multi-source subset) is validated at the
# mutual-exclusion boundary but its data-selection wiring lands in
# v0.3. The singular `source=` pin IS wired below (a post-fetch
Expand Down Expand Up @@ -1960,8 +1957,6 @@ def research(
# Phase 3.4: surface qc summary on df.attrs when the qc=True opt-in
# ran. Mode 1 parity rows themselves are unchanged (only attrs).
if qc_summary is not None and as_dataframe:
import contextlib

with contextlib.suppress(AttributeError):
result.attrs["qc"] = qc_summary

Expand All @@ -1970,8 +1965,6 @@ def research(
# df.attrs["source"]). No attrs change when source is None — the
# parity firewall keeps the default path untouched.
if source is not None and as_dataframe:
import contextlib

with contextlib.suppress(AttributeError):
result.attrs["source"] = source

Expand All @@ -1990,11 +1983,21 @@ def research(
# stamp this (pairs are heterogeneous), so the fallback to now()
# is acceptable here — but future pairs-level provenance work
# should populate result.attrs["retrieved_at"] for consistency.
# Mode 2 provenance parity: when source= is pinned, thread it explicitly
# into the wrapper the same way mode2.research_by_source does
# (source=source), so the wrapper contract doesn't depend on the
# df.attrs round-trip above. On this path the round-trip also works
# (as_dataframe=True guarantees a real DataFrame), so this is
# defense-in-depth, not a behavior fix. When source is None the default
# path is byte-identical (unchanged attrs-derived string).
wrap_source = (
source if source is not None else str(result.attrs.get("source", "mostlyright.research"))
)
return wrap_result(
result,
backend=backend, # type: ignore[arg-type]
return_type=return_type, # type: ignore[arg-type]
source=str(result.attrs.get("source", "mostlyright.research")),
source=wrap_source,
retrieved_at=result.attrs.get("retrieved_at"),
schema_id=None, # research() returns heterogeneous pairs, not a single schema
qc=qc_summary,
Expand Down
10 changes: 7 additions & 3 deletions packages/core/tests/test_namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@
``import mostlyright.weather`` for users who installed both distributions.
"""

from importlib.metadata import version as _dist_version

import mostlyright


def test_core_importable():
assert mostlyright.__version__ == "0.1.0rc1"
# __version__ is derived from the installed dist metadata (mostlyrightmd),
# not a hardcoded literal — keeps it in lockstep with pyproject on bumps.
assert mostlyright.__version__ == _dist_version("mostlyrightmd")


def test_path_extended_for_split_distribution():
Expand All @@ -29,11 +33,11 @@ def test_weather_subpackage_importable():
"""``import mostlyright.weather`` must succeed when mostlyrightmd-weather is installed."""
import mostlyright.weather

assert mostlyright.weather.__version__ == "0.1.0rc1"
assert mostlyright.weather.__version__ == _dist_version("mostlyrightmd-weather")


def test_markets_subpackage_importable():
"""``import mostlyright.markets`` must succeed when mostlyrightmd-markets is installed."""
import mostlyright.markets

assert mostlyright.markets.__version__ == "0.0.1"
assert mostlyright.markets.__version__ == _dist_version("mostlyrightmd-markets")
Loading
Loading