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
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,44 @@ All notable changes to `mostlyright`. The format follows [Keep a Changelog](http

## [Unreleased]

## [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).

### Added

- **`research(source=...)` single-source pin (both SDKs).** The singular
`source=` kwarg is now functional (was `NotImplementedError` since Phase 10):
observation rows are filtered to the requested source's alias-accept set
after the multi-source merge and before daily `obs_*` aggregation. Accepts
the full Mode 2 vocabulary (`iem`, `iem.archive`, `iem.live`, `awc`,
`awc.live`, `ghcnh`, `ghcnh.archive`) — byte-identical alias tables across
Python (`mostlyright.mode2._SOURCE_ALIASES`) and TS
(`RESEARCH_SOURCE_ALIASES`). Unknown sources raise before any network
fetch. `cli_*` settlement columns are unaffected (NWS CLI is independent
of the observation pin); the pinned DataFrame carries
`df.attrs["source"]`. Inherited v0.1 limitation documented: the
AWC > IEM > GHCNh merge runs before the pin filter, so pinning a
lower-priority source yields only rows the merge did not supersede.
`sources=[...]` (plural, multi-source subset) remains v0.3; its error
message no longer claims `source=` is unwired. The `source=None` default
path is byte-identical to 1.11.0 (parity gate green). TS `source: null`
follows the Phase 21 wire contract (null-as-absent). Follow-up nits: #107.

### Fixed

- **`mode2.research_by_source` returned whole calendar months instead of the
requested window.** The month-granular parquet cache flows through
`_fetch_observations_range` unfiltered, so a `KNYC` Jan 6–12 query
returned all 865 January rows (a Jan 28–Feb 3 query returned two full
months) — silent out-of-window leakage into backtest frames, including
the README's own `train = research_by_source(...)` example. Rows are now
trimmed to `[from_date, to_date]` by UTC date-part, inclusive, matching
the TS port's shipped filter exactly (cross-SDK lockstep); the +1-day
fetch extension remains as a fetch-width knob only. Every return shape
is fixed (DataFrame, `as_dataframe=False`, wrapper/backends). Callers
needing the last LST day's pre-midnight UTC tail should extend
`to_date` by one day.
- **Earnings venue-rule compound-word under-count (D-30).** Hyphenated compounds
(e.g. `supply-chain` for `chain`) were dropped from BOTH venue counts by an
inverted guard; they now count on both venues per each issuer's actual rule.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
{
"name": "@mostlyrightmd/weather (W1: AWC+CLI only, gzipped ESM)",
"path": "packages-ts/weather/dist/index.mjs",
"limit": "25 KB"
"limit": "26 KB"
},
{
"name": "@mostlyrightmd/markets (W1: Kalshi NHIGH/NLOW, gzipped ESM)",
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.11.0",
"version": "1.12.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",
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.11.0",
"version": "1.12.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",
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.11.0",
"version": "1.12.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",
Expand Down
45 changes: 45 additions & 0 deletions packages-ts/meta/src/mode2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,51 @@ export function isMode2Source(value: unknown): value is Mode2Source {
return typeof value === "string" && (MODE2_SOURCES as readonly string[]).includes(value);
}

// ── research({ source }) filter vocabulary — the WIDENED superset ──────────
// `researchBySource` (Mode 2 dispatch) narrows to the four dotted forms
// because a live-IEM fetcher gap means bare/live inputs can't be dispatched.
// `research({ source })` is a FILTER over already-fetched, already-merged
// rows, so it accepts Python's full vocabulary. The accepted names and
// alias-accept sets below are byte-identical to Python
// `mostlyright.mode2._VALID_OBSERVATION_SOURCES` / `_SOURCE_ALIASES`
// (packages/core/src/mostlyright/mode2.py:50-63).

/**
* research()-filter alias table — the Python-parity superset of
* {@link SOURCE_ALIASES}. Extends the narrow dispatch table (the four dotted
* forms) with the three bare parser tags (`iem`, `awc`, `ghcnh`) rather than
* re-declaring the shared dotted rows, so the dotted aliases have one source
* of truth. Mirrors Python `_SOURCE_ALIASES` (7 entries) verbatim.
*/
export const RESEARCH_SOURCE_ALIASES: ReadonlyMap<string, ReadonlySet<string>> = new Map<
string,
ReadonlySet<string>
>([
...SOURCE_ALIASES,
["iem", new Set(["iem", "iem.archive", "iem.live"])],
["awc", new Set(["awc", "awc.live"])],
["ghcnh", new Set(["ghcnh", "ghcnh.archive"])],
]);

/**
* Accepted `research({ source })` names — the KEYS of
* {@link RESEARCH_SOURCE_ALIASES}. Byte-identical to Python
* `_VALID_OBSERVATION_SOURCES`: `iem`, `iem.archive`, `iem.live`, `awc`,
* `awc.live`, `ghcnh`, `ghcnh.archive`.
*/
export const RESEARCH_OBSERVATION_SOURCES: ReadonlySet<string> = new Set(
RESEARCH_SOURCE_ALIASES.keys(),
);

/**
* Type-guard: true iff `value` is an accepted `research({ source })` name.
* WIDER than {@link isMode2Source} — accepts bare parser tags too, matching
* Python's `source in _VALID_OBSERVATION_SOURCES` membership check.
*/
export function isResearchObservationSource(value: unknown): boolean {
return typeof value === "string" && RESEARCH_OBSERVATION_SOURCES.has(value);
}

/**
* Throw {@link SourceMismatchError} if any row's `source` field
* disagrees with the expected source vocabulary. Rows missing the
Expand Down
78 changes: 65 additions & 13 deletions packages-ts/meta/src/research.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ import {
parseGhcnhPsv,
parseIemCsv,
} from "@mostlyrightmd/weather";
import {
RESEARCH_OBSERVATION_SOURCES,
RESEARCH_SOURCE_ALIASES,
isResearchObservationSource,
} from "./mode2.js";
import { type ResearchKwargsExtension, validateResearchKwargs } from "./research.types.js";

// Re-export PairsRow so callers can `import { research, type PairsRow } from "mostlyright"`.
Expand Down Expand Up @@ -109,9 +114,15 @@ export interface ResearchOptions extends ResearchKwargsExtension {
* StationOverrideWarning via `onWarning?`; output row carries
* `settlementMismatch: true`. Only valid with `contract` selector. */
stationOverride?: string;
/** Mode 1 source subset — dedupe within. Mutually exclusive with `source`. */
/** Mode 1 multi-source SUBSET selector — dedupe within. Validated in v0.2;
* its dedupe-within-subset wiring lands in v0.3 (passing it throws).
* Mutually exclusive with `source`. */
sources?: ReadonlyArray<string>;
/** Mode 2 single-source pin — error on mismatch. Mutually exclusive with `sources`. */
/** Mode 2 single-source pin. FUNCTIONAL: filters the merged observation
* rows to the requested source's alias set before daily obs_* aggregation
* (cli_* unaffected). Accepts any name in `RESEARCH_OBSERVATION_SOURCES`
* (the four dotted forms plus bare `iem`/`awc`/`ghcnh`). Mutually
* exclusive with `sources`. */
source?: string;
/** Attach per-issuer trade timeseries via @mostlyrightmd/markets/trades.
* Requires `contract` or `contracts`. */
Expand Down Expand Up @@ -785,7 +796,11 @@ async function fetchGhcnhWithCache(
* in `[fromDate, toDate]`. Each row carries:
* - `cli_*` populated from IEM CLI (final preferred per `mergeClimate`).
* - `obs_*` daily aggregates over the 3-source merged observations
* (AWC > IEM > GHCNh per `mergeObservations`).
* (AWC > IEM > GHCNh per `mergeObservations`). When `opts.source` is set
* (Mode 2 single-source pin), obs rows are filtered to that source's
* alias set AFTER the merge — so pinning a lower-priority source yields
* only the rows the merge did not supersede (see the inline note);
* `cli_*` is unaffected.
* - `fcst_*` unconditionally null (Mode 1).
* - `market_close_utc` formatted `YYYY-MM-DDTHH:MM:SSZ`.
*
Expand Down Expand Up @@ -854,18 +869,35 @@ 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 !== undefined) {
if (opts.sources !== undefined && opts.source != null) {
throw new Error("research(): sources and source are mutually exclusive");
}
// Iter-1 codex HIGH: sources / source validation is shipped in Phase 10
// v0.2 but the data-selection wiring lands in v0.3. Without this guard
// the station-path runs the full multi-source merge regardless — silent
// data-selection corruption.
if (opts.sources !== undefined || opts.source !== undefined) {
// `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) {
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 " +
"single-source pinning today pass `source=` (functional now), or use " +
"`researchBySource(station, source, ...)` from @mostlyrightmd/meta.",
);
}
// `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).
// 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(
"research(): sources / source validation surface is shipped in Phase 10 v0.2 " +
"but the data-selection wiring lands in v0.3. For Mode 2 single-source pinning " +
"today, use `researchBySource(station, source, ...)` from @mostlyrightmd/meta.",
`Mode 2 source must be one of ${JSON.stringify(
[...RESEARCH_OBSERVATION_SOURCES].sort(),
)}; got ${JSON.stringify(opts.source)}`,
);
}
if (opts.stationOverride !== undefined && !hasContract) {
Expand Down Expand Up @@ -977,12 +1009,32 @@ export async function research(
const sorted = sortByObservedAtThenSource(combinedRaw);
const merged = mergeObservations(sorted);

// ── Mode 2 single-source pin (opts.source) ────────────────────────────
// When `source` is set, keep only merged observation rows whose
// parser-emitted `source` tag is in the alias-accept set for the requested
// source — applied BEFORE daily obs_* aggregation. The cli_* settlement
// columns are UNAFFECTED (CLI is independent of the observation pin).
// Mirrors Python `mode2.research_by_source`'s post-merge source filter.
//
// Inherited limitation (Python mode2.py:85-93 parity): the AWC > IEM >
// GHCNh merge above runs on the full multi-source set, so pinning e.g.
// `iem` returns only the IEM rows AWC did NOT supersede on a colliding
// (station_code, observed_at, observation_type) key — not the full
// upstream IEM coverage. Callers needing pre-merge isolation should call
// the per-source fetchers in @mostlyrightmd/weather directly.
let observationsForBuild: ReadonlyArray<Observation> = merged;
if (opts.source != null) {
const accept = RESEARCH_SOURCE_ALIASES.get(opts.source);
// `accept` is guaranteed defined — `opts.source` validated above.
observationsForBuild = merged.filter((r) => accept?.has(r.source) ?? false);
}

const observationsByDate: Record<string, PairsObservationLike[]> = {};
// dates is guaranteed non-empty by buildDateList contract (throws on
// fromDate > toDate; both validated above).
const dateLo = dates[0] ?? "";
const dateHi = dates[dates.length - 1] ?? "";
for (const obs of merged) {
for (const obs of observationsForBuild) {
const settleDate = observedSettlementDate(obs.observed_at, resolved.code);
if (settleDate === null) continue;
if (settleDate < dateLo || settleDate > dateHi) continue;
Expand Down
11 changes: 8 additions & 3 deletions packages-ts/meta/tests/compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,15 @@ describe("research() Phase 10 signature validation", () => {
).rejects.toThrow(/v0.3/);
});

it("source alone raises v0.3 deferred (iter-1 codex HIGH)", async () => {
it("source alone is now FUNCTIONAL — an unknown source is rejected before any fetch", async () => {
// `source=` (single-source pin) is wired in this PR; it no longer defers
// to v0.3. Validation of an unknown source fires BEFORE any network call
// (mirrors Python `research_by_source`'s ValueError guard), so this stays
// network-free. The functional filter path over mocked fetchers is
// covered in research.test.ts.
await expect(
research("KNYC", "2025-01-06", "2025-01-12", { source: "iem.archive" }),
).rejects.toThrow(/researchBySource/);
research("KNYC", "2025-01-06", "2025-01-12", { source: "not-a-real-source" }),
).rejects.toThrow(/Mode 2 source must be one of/);
});

it("stationOverride requires contract", async () => {
Expand Down
40 changes: 40 additions & 0 deletions packages-ts/meta/tests/mode2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,26 @@ describe("researchBySource — iem.archive dispatch", () => {
});
expect(rows).toEqual([]);
});

it("window boundary: keeps rows ON fromDate + ON toDate; drops fromDate-1 + toDate+1", async () => {
// The window filter is inclusive on both ends (observed_at.slice(0,10)
// >= fromDate && <= toDate). Pin the exact boundary rows.
const csv = iemCsv([
makeIemRow({ valid: "2024-06-09 12:51" }), // fromDate-1 → DROP
makeIemRow({ valid: "2024-06-10 12:51" }), // fromDate → KEEP
makeIemRow({ valid: "2024-06-20 12:51" }), // toDate → KEEP
makeIemRow({ valid: "2024-06-21 12:51" }), // toDate+1 → DROP
]);
installFetchMock({ iemAsosCsv: csv });
const rows = await researchBySource("NYC", "iem.archive", "2024-06-10", "2024-06-20", {
iemPolitenessMs: 0,
});
const days = new Set(rows.map((r) => r.observed_at.slice(0, 10)));
expect(days.has("2024-06-10")).toBe(true); // ON fromDate kept
expect(days.has("2024-06-20")).toBe(true); // ON toDate kept
expect(days.has("2024-06-09")).toBe(false); // fromDate-1 dropped
expect(days.has("2024-06-21")).toBe(false); // toDate+1 dropped
});
});

describe("researchBySource — awc.live dispatch", () => {
Expand Down Expand Up @@ -315,6 +335,26 @@ describe("researchBySource — awc.live dispatch", () => {
const outDates = rows.map((r) => r.observed_at.slice(0, 10));
expect(outDates).not.toContain(outOfWindowFuture);
});

it("window boundary: keeps rows ON fromDate + ON toDate; drops fromDate-1 + toDate+1", async () => {
// Multi-day window pins fromDate and toDate as DISTINCT boundaries (the
// sibling test above uses a degenerate single-day window). The AWC branch
// filters on observed_at.slice(0,10) inclusive on both ends. The mock
// returns records verbatim regardless of `hours`, so fixed dates are safe.
const awc: AwcMockMetar[] = [
{ icaoId: "KNYC", obsTime: epochOfUtc("2024-06-09T12:00:00Z"), temp: 20 }, // fromDate-1 DROP
{ icaoId: "KNYC", obsTime: epochOfUtc("2024-06-10T12:00:00Z"), temp: 21 }, // fromDate KEEP
{ icaoId: "KNYC", obsTime: epochOfUtc("2024-06-20T12:00:00Z"), temp: 22 }, // toDate KEEP
{ icaoId: "KNYC", obsTime: epochOfUtc("2024-06-21T12:00:00Z"), temp: 23 }, // toDate+1 DROP
];
installFetchMock({ awc });
const rows = await researchBySource("NYC", "awc.live", "2024-06-10", "2024-06-20");
const days = new Set(rows.map((r) => r.observed_at.slice(0, 10)));
expect(days.has("2024-06-10")).toBe(true); // ON fromDate kept
expect(days.has("2024-06-20")).toBe(true); // ON toDate kept
expect(days.has("2024-06-09")).toBe(false); // fromDate-1 dropped
expect(days.has("2024-06-21")).toBe(false); // toDate+1 dropped
});
});

describe("researchBySource — ghcnh.archive dispatch", () => {
Expand Down
Loading
Loading