Skip to content
Open
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
7 changes: 6 additions & 1 deletion answers/usgs-quakes-near.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@
"type": "time"
}
],
"may_be_empty": true
"may_be_empty": true,
"param_bounds": {
"properties.mag": {
"min": "min_mag"
}
}
},
{
"name": "quake_count",
Expand Down
7 changes: 6 additions & 1 deletion answers/usgs-quakes-state.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@
"type": "time"
}
],
"may_be_empty": true
"may_be_empty": true,
"param_bounds": {
"properties.mag": {
"min": "min_mag"
}
}
},
{
"name": "quake_count",
Expand Down
17 changes: 16 additions & 1 deletion docs/SCHEMA.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Source record schema (v1, answers spec v1.2)
# Source record schema (v1, answers spec v1.4)

Each source is one JSON object per line in `sources/**/*.jsonl`. Users' suggestions arrive through the Library API and `sourcetool ingest` writes them to `sources/suggested/<date>.jsonl` with tier `harvested` and origin `{by: "user-suggestion"}`. `sourcetool/schema.py` holds the rules, and CI, `sourcetool` and the app all run the same validator.

Expand Down Expand Up @@ -46,6 +46,21 @@ Example (MLB postseason game times are placeholders until the league sets them):
"path": "dates[0].games[0].gameDate", "tbd_if": {"path": "dates[0].games[0].status.startTimeTBD", "equals": true}}
```

### Answers spec v1.4: the parameter that bounds a cell

Optional and closed, on a `list` or `columns` answer. It lets the app push a comparator the user asked for into the address, so the source returns the rows the card keeps; the card's own row filter stays.

| Key | On | Meaning |
|---|---|---|
| `param_bounds` | list, columns | `{<cell path>: {min?: <param>, max?: <param>}}`, 1 to 4 cells: the parameter of the address that bounds that `number` cell from below (`min`) or above (`max`). The cell is one of the answer's own `number` fields, each side names one of the record's `access.params` whose `example` is a number, and a parameter bounds one side of one cell only. "Earthquakes above magnitude 4" fetches with `minmagnitude=4` instead of the default 2.5, and the card still keeps only the rows above 4. |

Example (USGS state earthquakes, `min_mag` filled from its default 2.5):

```json
{"name": "quakes", "kind": "list", "path": "features", "row": [{"path": "properties.mag", "label": "Magnitude", "type": "number"}, ...],
"param_bounds": {"properties.mag": {"min": "min_mag"}}}
```

### Taxonomy keys

A subcategory may declare `expects`: the components a complete answer holds (closed names from the `measure` list plus `observed_time`, `wave_period`, `score`, `opponent`, `start_time`, `venue`, `rank`, `record`), so a card that lacks one says so ("this source doesn't report: wind"). Its `policy` may declare `measure`: `{<station resolver>: <column the station must report>}` (`water/water_temperature`: `{"buoy": "WTMP"}`), and a nearest-station fill then skips stations whose `attrs.measures` lack that column. `build` writes both into the pack: `library_taxonomy.policy` holds the policy (with its `measure`) and the subcategory's `expects`.
Expand Down
4 changes: 2 additions & 2 deletions sources/curated/core.jsonl

Large diffs are not rendered by default.

42 changes: 39 additions & 3 deletions sourcetool/answers.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,11 @@
"record")
COMMON = {"name", "label", "words", "primary", "kind"}
KEYS = {"value": COMMON | {"path", "type", "unit", "unit_path", "codes", "utc", "window", "measure", "tbd_if"},
"list": COMMON | {"path", "row", "newest_first", "may_be_empty", "filter", "axis"},
"columns": COMMON | {"columns", "limit", "axis"}}
"list": COMMON | {"path", "row", "newest_first", "may_be_empty", "filter", "axis", "param_bounds"},
"columns": COMMON | {"columns", "limit", "axis", "param_bounds"}}
# v1.4: `param_bounds` names the parameter of the address that bounds a number cell from below / above
BOUND_SIDES = ("min", "max")
MAX_BOUNDS = 4
ROW_KEYS = {"path", "label", "type", "unit", "unit_path", "utc", "tbd_if"} # utc: zoneless times are UTC
COLUMN_KEYS = (ROW_KEYS - {"tbd_if"}) | {"codes"}
FILE_KEYS = {"source_id", "answers", "sample_url", "checked"}
Expand Down Expand Up @@ -146,6 +149,39 @@ def _axis_problems(w: str, a: dict) -> list[str]:
return errs


def _bounds_problems(w: str, a: dict, params) -> list[str]:
"""v1.4 `param_bounds: {<cell path>: {min?: <param>, max?: <param>}}` says which parameter of the address
bounds a number cell from below or above (USGS `minmagnitude` for `properties.mag`), so the app can push an
asked comparator ("above magnitude 4") into the address while its row filter stays. The cell is one of the
answer's own `number` fields; each side names a parameter of the source (whose example is a number, when the
examples are known), and one parameter bounds one side only."""
if "param_bounds" not in a:
return []
pb = a["param_bounds"]
if not isinstance(pb, dict) or not 1 <= len(pb) <= MAX_BOUNDS:
return [f"{w}: param_bounds must map 1-{MAX_BOUNDS} number cells to {{min, max}}"]
fields = a.get("row") if a.get("kind") == "list" else a.get("columns")
numbers = {f.get("path") for f in fields or [] if isinstance(f, dict) and f.get("type") == "number"}
errs, used = [], []
for cell, sides in pb.items():
if cell not in numbers:
errs.append(f"{w}: param_bounds cell {cell!r} is not one of its number fields "
f"{sorted(n for n in numbers if n)}")
if not isinstance(sides, dict) or not sides or set(sides) - set(BOUND_SIDES):
errs.append(f"{w}: param_bounds[{cell!r}] must be {{min?, max?}} naming parameters")
continue
for side, name in sides.items():
if not isinstance(name, str) or name not in params:
errs.append(f"{w}: param_bounds {side} {name!r} is not a parameter of this source")
continue
if isinstance(params, dict) and not _is_number(params[name]):
errs.append(f"{w}: param_bounds {side} {name!r} is not a number parameter (example {params[name]!r})")
used.append(name)
if len(set(used)) < len(used):
errs.append(f"{w}: a parameter bounds one side of one cell only")
return errs


def _param_problems(w: str, a: dict, params) -> list[str]:
"""Every `{param}` an answer names (in any path or filter) is one of the record's access.params."""
found = [a.get("path"), a.get("unit_path")]
Expand Down Expand Up @@ -254,7 +290,7 @@ def answer_problems(answers, params=()) -> list[str]:
if "limit" in a and (isinstance(lim, bool) or not isinstance(lim, int) or not 1 <= lim <= 100):
errs.append(f"{w}: limit must be a whole number 1-100")
if kind != "value":
errs += _axis_problems(w, a)
errs += _axis_problems(w, a) + _bounds_problems(w, a, params)
errs += _param_problems(w, a, params)
rows_primary = primary["list"] + primary["columns"]
if rows_primary and primary["value"]:
Expand Down
4 changes: 4 additions & 0 deletions sourcetool/fills.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
("frankfurter", "quote"): R("currency", many_index=1),
("mlb", "team"): R("team_mlb"),
("mlb", "since"): CLOCK("%Y-%m-%d", -7),
# an upcoming calendar asks from today forward: its next_* / upcoming answers read the first date the address
# returns, so the generic date window (start -30 .. end 0) handed them a game already played (Round 20, Q3b)
("mlb", "start"): CLOCK("%Y-%m-%d"), ("mlb", "end"): CLOCK("%Y-%m-%d", 30),
("finnhub", "start"): CLOCK("%Y-%m-%d"), ("finnhub", "end"): CLOCK("%Y-%m-%d", 30),
("nhl", "team"): R("team_nhl"),
("espn", "sport"): R("team_espn", "attrs.sport", fallback=DEFAULT("football")),
("espn", "league"): R("team_espn", "attrs.league", fallback=DEFAULT("nfl")),
Expand Down
18 changes: 18 additions & 0 deletions tests/test_answers.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,24 @@ def test_columns_rules():
bad([c], "wmo_weather")


def test_param_bounds_v14():
"""v1.4: `param_bounds` names the parameter that bounds a number cell (USGS minmagnitude for properties.mag);
the cell is one of the answer's number fields, each side a number parameter of the source, one side each."""
params = {"min_mag": "2.5", "radius_km": "200", "start": "2026-09-01", "lat": "37.77"}
ok = rows(param_bounds={"properties.mag": {"min": "min_mag"}})
assert answer_problems([ok], params) == []
assert answer_problems([ok], {"min_mag"}) == [] # names only (schema.validate_record): no example to check
assert answer_problems([cols(param_bounds={"daily.temperature_2m_max": {"max": "radius_km"}})], params) == []
bad([rows(param_bounds={"properties.place": {"min": "min_mag"}})], "not one of its number fields", params)
bad([rows(param_bounds={"properties.mag": {"min": "magnitude"}})], "not a parameter", params)
bad([rows(param_bounds={"properties.mag": {"min": "start"}})], "not a number parameter", params)
bad([rows(param_bounds={"properties.mag": {"low": "min_mag"}})], "{min?, max?}", params)
bad([rows(param_bounds={"properties.mag": {}})], "{min?, max?}", params)
bad([rows(param_bounds={})], "1-4", params)
bad([rows(param_bounds={"properties.mag": {"min": "min_mag", "max": "min_mag"}})], "one side", params)
bad([value(param_bounds={"current.temperature_2m": {"min": "min_mag"}})], "unknown keys", params)


def test_primary_limits():
assert answer_problems([value(primary=False)]) == [] # primary is a maximum, not a minimum (v1.1)
bad([value(str(c)) for c in "abcde"], "at most 4")
Expand Down
11 changes: 11 additions & 0 deletions tests/test_records_v12.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,17 @@ def test_mlb_team_schedule_marks_tbd_times_and_names_the_game():
assert start["tbd_if"] == {"path": "status.startTimeTBD", "equals": True}


def test_upcoming_calendars_ask_from_today_forward():
"""Round 20 (Q3b fix round): a calendar whose next_* / upcoming answers read the first date the address
returns asks from today forward — the generic date window (start -30 .. end 0) handed "next Braves game" a
game already played. The fill rules carry it, so a `sourcetool fills` run keeps it."""
from sourcetool.fills import fill_for
for sid in ("mlb-team-schedule", "finnhub-earnings-calendar"):
params = [p for p in RECORDS[sid]["access"]["params"] if p["name"] in ("start", "end")]
assert {p["name"]: p["fill"]["offset_days"] for p in params} == {"start": 0, "end": 30}, sid
assert all(fill_for(RECORDS[sid], p) == p["fill"] for p in params), sid


# --- water: marine coverage, direction, swell, surf --------------------------------------------------

def test_open_meteo_marine_is_ocean_and_coast_only_and_reads_direction_and_swell():
Expand Down
Loading