diff --git a/answers/usgs-quakes-near.json b/answers/usgs-quakes-near.json index bd29cfd..a8a4ba5 100644 --- a/answers/usgs-quakes-near.json +++ b/answers/usgs-quakes-near.json @@ -37,7 +37,12 @@ "type": "time" } ], - "may_be_empty": true + "may_be_empty": true, + "param_bounds": { + "properties.mag": { + "min": "min_mag" + } + } }, { "name": "quake_count", diff --git a/answers/usgs-quakes-state.json b/answers/usgs-quakes-state.json index ea689f1..e74d10b 100644 --- a/answers/usgs-quakes-state.json +++ b/answers/usgs-quakes-state.json @@ -36,7 +36,12 @@ "type": "time" } ], - "may_be_empty": true + "may_be_empty": true, + "param_bounds": { + "properties.mag": { + "min": "min_mag" + } + } }, { "name": "quake_count", diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 88d9832..37e2c91 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -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/.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. @@ -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 | `{: {min?: , max?: }}`, 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`: `{: }` (`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`. diff --git a/sources/curated/core.jsonl b/sources/curated/core.jsonl index 4c8a1c6..946395b 100644 --- a/sources/curated/core.jsonl +++ b/sources/curated/core.jsonl @@ -85,7 +85,7 @@ {"access": {"auth": "none", "contact_ua": false, "docs_url": "https://www.federalregister.gov/reader-aids/developer-resources", "headers": {}, "kind": "http_json", "params": [{"example": "environmental-protection-agency", "fill": {"field": "key", "from": "resolver", "resolver": "fr_agency"}, "kind": "agency", "name": "agency", "required": true}], "url_template": "https://www.federalregister.gov/api/v1/documents.json?per_page=20&order=newest&conditions[agencies][]={agency}"}, "categories": ["civic/regulations"], "coverage": {"entity": "", "geo": "US"}, "description": "Newest rules, proposed rules and notices, optionally by agency.", "examples": ["new EPA rules", "federal register today"], "freshness": {"cadence": "daily"}, "id": "federal-register-latest", "kinds": ["latest_items"], "name": "Federal Register documents", "notes": "", "origin": {"at": "2026-09-28T04:10:32Z", "by": "bootstrap/core_seed"}, "provider": {"authority": "official", "id": "federalregister", "name": "Federal Register", "url": "https://www.federalregister.gov"}, "terms": {"note": "US government work", "status": "public_domain", "terms_url": "https://www.federalregister.gov/reader-aids/developer-resources"}, "tier": "curated", "validation": {"bytes": 36990, "checked_at": "2026-09-28T04:10:34Z", "http": 200, "note": "json", "robots": "allow", "status": "ok"}, "votes": {"no": 0, "yes": 0}} {"access": {"auth": "none", "contact_ua": false, "docs_url": "https://www.federalregister.gov/reader-aids/developer-resources", "headers": {}, "kind": "http_json", "params": [], "url_template": "https://www.federalregister.gov/api/v1/public-inspection-documents/current.json"}, "categories": ["civic/regulations"], "coverage": {"entity": "", "geo": "US"}, "description": "Documents on public inspection today (before publication).", "examples": ["upcoming federal rules"], "freshness": {"cadence": "daily"}, "id": "federal-register-public-inspection", "kinds": ["latest_items"], "name": "Federal Register public inspection", "notes": "", "origin": {"at": "2026-09-28T04:10:32Z", "by": "bootstrap/core_seed"}, "provider": {"authority": "official", "id": "federalregister", "name": "Federal Register", "url": "https://www.federalregister.gov"}, "terms": {"note": "US government work", "status": "public_domain", "terms_url": "https://www.federalregister.gov/reader-aids/developer-resources"}, "tier": "curated", "validation": {"bytes": 168493, "checked_at": "2026-09-28T04:10:34Z", "http": 200, "note": "json", "robots": "allow", "status": "ok"}, "votes": {"no": 0, "yes": 0}} {"access": {"auth": "none", "contact_ua": false, "docs_url": "https://www.fema.gov/about/openfema/terms-conditions", "headers": {}, "kind": "http_json", "params": [{"example": "FL", "fill": {"field": "key", "from": "resolver", "resolver": "us_state"}, "kind": "us_state", "name": "state", "required": true}], "url_template": "https://www.fema.gov/api/open/v2/DisasterDeclarationsSummaries?$filter=state%20eq%20%27{state}%27&$orderby=declarationDate%20desc&$top=20"}, "categories": ["hazards/emergencies"], "coverage": {"entity": "", "geo": "US"}, "description": "Most recent federal disaster declarations for a state.", "examples": ["disaster declarations in Florida"], "freshness": {"cadence": "daily"}, "id": "fema-declarations-state", "kinds": ["latest_items"], "name": "FEMA disaster declarations by state", "notes": "", "origin": {"at": "2026-09-28T04:10:32Z", "by": "bootstrap/core_seed"}, "provider": {"authority": "official", "id": "fema", "name": "FEMA (OpenFEMA)", "url": "https://www.fema.gov/about/openfema"}, "terms": {"note": "US government work; OpenFEMA terms", "status": "public_domain", "terms_url": "https://www.fema.gov/about/openfema/terms-conditions"}, "tier": "curated", "validation": {"bytes": 18405, "checked_at": "2026-09-28T04:10:34Z", "http": 200, "note": "json", "robots": "allow", "status": "ok"}, "votes": {"no": 0, "yes": 0}} -{"access": {"auth": "free_key", "contact_ua": false, "docs_url": "https://finnhub.io/terms-of-service", "headers": {}, "kind": "http_json", "params": [{"example": "2026-09-27", "fill": {"format": "%Y-%m-%d", "from": "clock", "offset_days": -30}, "kind": "date", "name": "start", "required": true}, {"example": "2026-10-10", "fill": {"format": "%Y-%m-%d", "from": "clock", "offset_days": 0}, "kind": "date", "name": "end", "required": true}, {"example": null, "fill": {"from": "vault_key"}, "kind": "key", "name": "key", "required": true}], "url_template": "https://finnhub.io/api/v1/calendar/earnings?from={start}&to={end}&token={key}"}, "categories": ["markets/filings"], "coverage": {"entity": "", "geo": "US"}, "description": "Upcoming earnings dates with estimates.", "examples": ["earnings this week", "when does Nvidia report"], "freshness": {"cadence": "daily"}, "id": "finnhub-earnings-calendar", "kinds": ["schedule", "next_event"], "name": "Finnhub earnings calendar", "notes": "", "origin": {"at": "2026-09-28T04:10:32Z", "by": "bootstrap/core_seed"}, "provider": {"authority": "aggregator", "id": "finnhub", "name": "Finnhub", "url": "https://finnhub.io"}, "terms": {"note": "free key, 60 requests/min", "status": "terms_allow", "terms_url": "https://finnhub.io/terms-of-service"}, "tier": "curated", "validation": {"checked_at": "2026-09-28T04:10:35Z", "note": "needs a user value or key to probe", "status": "unvalidated"}, "votes": {"no": 0, "yes": 0}} +{"access": {"auth": "free_key", "contact_ua": false, "docs_url": "https://finnhub.io/terms-of-service", "headers": {}, "kind": "http_json", "params": [{"example": "2026-09-27", "fill": {"format": "%Y-%m-%d", "from": "clock", "offset_days": 0}, "kind": "date", "name": "start", "required": true}, {"example": "2026-10-10", "fill": {"format": "%Y-%m-%d", "from": "clock", "offset_days": 30}, "kind": "date", "name": "end", "required": true}, {"example": null, "fill": {"from": "vault_key"}, "kind": "key", "name": "key", "required": true}], "url_template": "https://finnhub.io/api/v1/calendar/earnings?from={start}&to={end}&token={key}"}, "categories": ["markets/filings"], "coverage": {"entity": "", "geo": "US"}, "description": "Upcoming earnings dates with estimates.", "examples": ["earnings this week", "when does Nvidia report"], "freshness": {"cadence": "daily"}, "id": "finnhub-earnings-calendar", "kinds": ["schedule", "next_event"], "name": "Finnhub earnings calendar", "notes": "", "origin": {"at": "2026-09-28T04:10:32Z", "by": "bootstrap/core_seed"}, "provider": {"authority": "aggregator", "id": "finnhub", "name": "Finnhub", "url": "https://finnhub.io"}, "terms": {"note": "free key, 60 requests/min", "status": "terms_allow", "terms_url": "https://finnhub.io/terms-of-service"}, "tier": "curated", "validation": {"checked_at": "2026-09-28T04:10:35Z", "note": "needs a user value or key to probe", "status": "unvalidated"}, "votes": {"no": 0, "yes": 0}} {"access": {"auth": "free_key", "contact_ua": false, "docs_url": "https://finnhub.io/terms-of-service", "headers": {}, "kind": "http_json", "params": [{"example": "AAPL", "fill": {"field": "key", "from": "resolver", "resolver": "ticker"}, "kind": "ticker", "name": "ticker", "required": true}, {"example": null, "fill": {"from": "vault_key"}, "kind": "key", "name": "key", "required": true}], "url_template": "https://finnhub.io/api/v1/quote?symbol={ticker}&token={key}"}, "categories": ["markets/stocks", "markets/indices"], "coverage": {"entity": "", "geo": "US"}, "description": "Real-time quote for a US ticker.", "examples": ["live apple stock price"], "freshness": {"cadence": "realtime"}, "id": "finnhub-quote", "kinds": ["current_value"], "name": "Finnhub quote", "notes": "", "origin": {"at": "2026-09-28T04:10:32Z", "by": "bootstrap/core_seed"}, "provider": {"authority": "aggregator", "id": "finnhub", "name": "Finnhub", "url": "https://finnhub.io"}, "terms": {"note": "free key, 60 requests/min", "status": "terms_allow", "terms_url": "https://finnhub.io/terms-of-service"}, "tier": "curated", "validation": {"checked_at": "2026-09-28T04:10:35Z", "note": "needs a user value or key to probe", "status": "unvalidated"}, "votes": {"no": 0, "yes": 0}} {"access": {"auth": "free_key", "contact_ua": false, "docs_url": "https://www.football-data.org/terms", "headers": {"X-Auth-Token": "{key}"}, "kind": "http_json", "params": [{"example": "PL", "fill": {"field": "key", "from": "resolver", "resolver": "soccer_competition"}, "kind": "league", "name": "competition", "required": true}, {"example": null, "fill": {"from": "vault_key"}, "kind": "key", "name": "key", "required": true}], "url_template": "https://api.football-data.org/v4/competitions/{competition}/matches?status=SCHEDULED"}, "categories": ["sports/schedules", "sports/results"], "coverage": {"entity": "", "geo": "global"}, "description": "Upcoming and recent matches for a competition.", "examples": ["premier league fixtures this weekend"], "freshness": {"cadence": "hourly"}, "id": "football-data-matches", "kinds": ["schedule", "latest_items"], "name": "Soccer fixtures (football-data.org)", "notes": "", "origin": {"at": "2026-09-28T04:10:32Z", "by": "bootstrap/core_seed"}, "provider": {"authority": "aggregator", "id": "football-data", "name": "football-data.org", "url": "https://www.football-data.org"}, "terms": {"note": "free key, 10 requests/min", "status": "terms_allow", "terms_url": "https://www.football-data.org/terms"}, "tier": "curated", "validation": {"checked_at": "2026-09-28T04:10:35Z", "note": "needs the user's key in a header", "status": "unvalidated"}, "votes": {"no": 0, "yes": 0}} {"access": {"auth": "free_key", "contact_ua": false, "docs_url": "https://www.football-data.org/terms", "headers": {"X-Auth-Token": "{key}"}, "kind": "http_json", "params": [{"example": "PL", "fill": {"field": "key", "from": "resolver", "resolver": "soccer_competition"}, "kind": "league", "name": "competition", "required": true}, {"example": null, "fill": {"from": "vault_key"}, "kind": "key", "name": "key", "required": true}], "url_template": "https://api.football-data.org/v4/competitions/{competition}/standings"}, "categories": ["sports/standings"], "coverage": {"entity": "", "geo": "global"}, "description": "Standings for a soccer competition (EPL, MLS...).", "examples": ["premier league table", "epl standings"], "freshness": {"cadence": "hourly"}, "id": "football-data-standings", "kinds": ["ranking"], "name": "Soccer league table (football-data.org)", "notes": "", "origin": {"at": "2026-09-28T04:10:32Z", "by": "bootstrap/core_seed"}, "provider": {"authority": "aggregator", "id": "football-data", "name": "football-data.org", "url": "https://www.football-data.org"}, "terms": {"note": "free key, 10 requests/min", "status": "terms_allow", "terms_url": "https://www.football-data.org/terms"}, "tier": "curated", "validation": {"checked_at": "2026-09-28T04:10:35Z", "note": "needs the user's key in a header", "status": "unvalidated"}, "votes": {"no": 0, "yes": 0}} @@ -163,7 +163,7 @@ {"access": {"auth": "none", "contact_ua": false, "docs_url": "http://gdx.mlb.com/components/copyright.txt", "headers": {}, "kind": "http_json", "params": [{"example": "2026-09-27", "fill": {"format": "%Y-%m-%d", "from": "clock", "offset_days": 0}, "kind": "date", "name": "date", "required": true}], "url_template": "https://statsapi.mlb.com/api/v1/schedule?sportId=1&date={date}&hydrate=linescore"}, "categories": ["sports/scores", "sports/schedules", "sports/results"], "coverage": {"entity": "MLB", "geo": "US"}, "description": "MLB games for a date with status and scores.", "examples": ["mlb scores today", "did the Braves win", "baseball games tonight"], "freshness": {"cadence": "minutes"}, "id": "mlb-schedule", "kinds": ["result", "schedule", "current_value", "latest_items"], "name": "MLB schedule and scores", "notes": "", "origin": {"at": "2026-09-28T04:10:32Z", "by": "bootstrap/core_seed"}, "provider": {"authority": "primary", "id": "mlb", "name": "MLB Stats API", "url": "https://statsapi.mlb.com"}, "terms": {"note": "individual non-commercial use per MLB copyright notice", "status": "terms_allow", "terms_url": "http://gdx.mlb.com/components/copyright.txt"}, "tier": "curated", "validation": {"bytes": 62024, "checked_at": "2026-09-28T04:10:36Z", "http": 200, "note": "json", "robots": "allow", "status": "ok"}, "votes": {"no": 0, "yes": 0}} {"access": {"auth": "none", "contact_ua": false, "docs_url": "http://gdx.mlb.com/components/copyright.txt", "headers": {}, "kind": "http_json", "params": [], "url_template": "https://statsapi.mlb.com/api/v1/standings?leagueId=103,104&standingsTypes=regularSeason"}, "categories": ["sports/standings"], "coverage": {"entity": "MLB", "geo": "US"}, "description": "Current MLB division standings.", "examples": ["mlb standings", "al east standings"], "freshness": {"cadence": "hourly"}, "id": "mlb-standings", "kinds": ["ranking"], "name": "MLB standings", "notes": "", "origin": {"at": "2026-09-28T04:10:32Z", "by": "bootstrap/core_seed"}, "provider": {"authority": "primary", "id": "mlb", "name": "MLB Stats API", "url": "https://statsapi.mlb.com"}, "terms": {"note": "individual non-commercial use per MLB copyright notice", "status": "terms_allow", "terms_url": "http://gdx.mlb.com/components/copyright.txt"}, "tier": "curated", "validation": {"bytes": 80986, "checked_at": "2026-09-28T04:10:36Z", "http": 200, "note": "json", "robots": "allow", "status": "ok"}, "votes": {"no": 0, "yes": 0}} {"access": {"auth": "none", "contact_ua": false, "docs_url": "http://gdx.mlb.com/components/copyright.txt", "headers": {}, "kind": "http_json", "params": [{"example": "147", "fill": {"field": "key", "from": "resolver", "resolver": "team_mlb"}, "kind": "team", "name": "team", "required": true}, {"example": "2026-09-21", "fill": {"format": "%Y-%m-%d", "from": "clock", "offset_days": -7}, "kind": "date", "name": "since", "required": true}, {"example": "2026-09-28", "fill": {"format": "%Y-%m-%d", "from": "clock", "offset_days": 0}, "kind": "date", "name": "until", "required": true}], "url_template": "https://statsapi.mlb.com/api/v1/schedule?sportId=1&teamId={team}&startDate={since}&endDate={until}"}, "categories": ["sports/results"], "coverage": {"entity": "", "geo": "US"}, "description": "One MLB team's games over the past week, with the final score of each.", "examples": ["Yankees score", "did the Yankees win", "Mets final score", "Cubs results"], "freshness": {"cadence": "minutes"}, "id": "mlb-team-results", "kinds": ["result", "latest_items"], "name": "MLB team recent results", "notes": "", "origin": {"at": "2026-09-29T04:00:00Z", "by": "curated/sports-sources"}, "provider": {"authority": "primary", "id": "mlb", "name": "MLB Stats API", "url": "https://statsapi.mlb.com"}, "terms": {"note": "individual non-commercial use per MLB copyright notice", "status": "terms_allow", "terms_url": "http://gdx.mlb.com/components/copyright.txt"}, "tier": "curated", "validation": {"bytes": 10034, "checked_at": "2026-09-29T03:25:40Z", "http": 200, "note": "json", "robots": "allow", "status": "ok"}, "votes": {"no": 0, "yes": 0}} -{"access": {"auth": "none", "contact_ua": false, "docs_url": "http://gdx.mlb.com/components/copyright.txt", "headers": {}, "kind": "http_json", "params": [{"example": "144", "fill": {"field": "key", "from": "resolver", "resolver": "team_mlb"}, "kind": "team", "name": "team", "required": true}, {"example": "2026-09-27", "fill": {"format": "%Y-%m-%d", "from": "clock", "offset_days": -30}, "kind": "date", "name": "start", "required": true}, {"example": "2026-10-10", "fill": {"format": "%Y-%m-%d", "from": "clock", "offset_days": 0}, "kind": "date", "name": "end", "required": true}], "url_template": "https://statsapi.mlb.com/api/v1/schedule?sportId=1&teamId={team}&startDate={start}&endDate={end}"}, "categories": ["sports/schedules"], "coverage": {"entity": "", "geo": "US"}, "description": "Upcoming games for one MLB team.", "examples": ["when do the Braves play next"], "freshness": {"cadence": "daily"}, "id": "mlb-team-schedule", "kinds": ["schedule", "next_event"], "name": "MLB team schedule", "notes": "", "origin": {"at": "2026-09-28T04:10:32Z", "by": "bootstrap/core_seed"}, "provider": {"authority": "primary", "id": "mlb", "name": "MLB Stats API", "url": "https://statsapi.mlb.com"}, "terms": {"note": "individual non-commercial use per MLB copyright notice", "status": "terms_allow", "terms_url": "http://gdx.mlb.com/components/copyright.txt"}, "tier": "curated", "validation": {"bytes": 5852, "checked_at": "2026-09-28T04:10:36Z", "http": 200, "note": "json", "robots": "allow", "status": "ok"}, "votes": {"no": 0, "yes": 0}} +{"access": {"auth": "none", "contact_ua": false, "docs_url": "http://gdx.mlb.com/components/copyright.txt", "headers": {}, "kind": "http_json", "params": [{"example": "144", "fill": {"field": "key", "from": "resolver", "resolver": "team_mlb"}, "kind": "team", "name": "team", "required": true}, {"example": "2026-09-27", "fill": {"format": "%Y-%m-%d", "from": "clock", "offset_days": 0}, "kind": "date", "name": "start", "required": true}, {"example": "2026-10-10", "fill": {"format": "%Y-%m-%d", "from": "clock", "offset_days": 30}, "kind": "date", "name": "end", "required": true}], "url_template": "https://statsapi.mlb.com/api/v1/schedule?sportId=1&teamId={team}&startDate={start}&endDate={end}"}, "categories": ["sports/schedules"], "coverage": {"entity": "", "geo": "US"}, "description": "Upcoming games for one MLB team.", "examples": ["when do the Braves play next"], "freshness": {"cadence": "daily"}, "id": "mlb-team-schedule", "kinds": ["schedule", "next_event"], "name": "MLB team schedule", "notes": "", "origin": {"at": "2026-09-28T04:10:32Z", "by": "bootstrap/core_seed"}, "provider": {"authority": "primary", "id": "mlb", "name": "MLB Stats API", "url": "https://statsapi.mlb.com"}, "terms": {"note": "individual non-commercial use per MLB copyright notice", "status": "terms_allow", "terms_url": "http://gdx.mlb.com/components/copyright.txt"}, "tier": "curated", "validation": {"bytes": 5852, "checked_at": "2026-09-28T04:10:36Z", "http": 200, "note": "json", "robots": "allow", "status": "ok"}, "votes": {"no": 0, "yes": 0}} {"access": {"auth": "none", "contact_ua": false, "docs_url": "http://gdx.mlb.com/components/copyright.txt", "headers": {}, "kind": "http_json", "params": [], "url_template": "https://statsapi.mlb.com/api/v1/standings?leagueId=103,104&standingsTypes=wildCard"}, "categories": ["sports/standings"], "coverage": {"entity": "MLB", "geo": "US"}, "description": "American and National League wild card standings: each team's record and games back of the last wild card spot (the playoff race for the postseason berths and seeds).", "examples": ["MLB wild card standings", "AL wild card race", "NL wild card", "who has the last wild card spot"], "freshness": {"cadence": "hourly"}, "id": "mlb-wildcard-standings", "kinds": ["ranking"], "name": "MLB wild card standings", "notes": "records[0] is the American League (leagueId 103), records[1] the National League (104)", "origin": {"at": "2026-09-29T16:00:00Z", "by": "curated/library-records-0929"}, "provider": {"authority": "primary", "id": "mlb", "name": "MLB Stats API", "url": "https://statsapi.mlb.com"}, "terms": {"note": "individual non-commercial use per MLB copyright notice", "status": "terms_allow", "terms_url": "http://gdx.mlb.com/components/copyright.txt"}, "tier": "curated", "validation": {"status": "unvalidated"}, "votes": {"no": 0, "yes": 0}} {"access": {"auth": "none", "contact_ua": false, "docs_url": "https://new.mta.info/developers", "headers": {}, "kind": "gtfs_rt", "params": [], "url_template": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/camsys%2Fall-alerts"}, "categories": ["travel/transit_alerts"], "coverage": {"entity": "", "geo": "US-NY"}, "description": "NYC subway, bus and rail service alerts (GTFS-realtime).", "examples": ["subway delays", "mta service changes"], "freshness": {"cadence": "realtime"}, "id": "mta-alerts", "kinds": ["alerts", "status"], "name": "MTA service alerts", "notes": "", "origin": {"at": "2026-09-28T04:10:32Z", "by": "bootstrap/core_seed"}, "provider": {"authority": "official", "id": "mta", "name": "MTA (New York)", "url": "https://new.mta.info"}, "terms": {"note": "MTA developer terms. Operator ruling 2026-09-29: each person's own SmartBrain fetches the feed for that person, no different from browsing the site; SecureCloudGroup hosts or redistributes nothing.", "status": "terms_allow", "terms_url": "https://new.mta.info/developers"}, "tier": "curated", "validation": {"bytes": 664283, "checked_at": "2026-09-28T04:10:36Z", "http": 200, "note": "protobuf 664283B", "robots": "allow", "status": "ok"}, "votes": {"no": 0, "yes": 0}} {"access": {"auth": "none", "contact_ua": false, "docs_url": "https://new.mta.info/developers", "headers": {}, "kind": "gtfs_rt", "params": [], "url_template": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-ace"}, "categories": ["travel/transit"], "coverage": {"entity": "", "geo": "US-NY"}, "description": "NYC subway GTFS-realtime trip updates for the A/C/E lines.", "examples": ["next A train", "subway arrivals"], "freshness": {"cadence": "realtime"}, "id": "mta-subway-ace", "kinds": ["next_event", "status"], "name": "MTA subway realtime (A/C/E)", "notes": "", "origin": {"at": "2026-09-28T04:10:32Z", "by": "bootstrap/core_seed"}, "provider": {"authority": "official", "id": "mta", "name": "MTA (New York)", "url": "https://new.mta.info"}, "terms": {"note": "MTA developer terms. Operator ruling 2026-09-29: each person's own SmartBrain fetches the feed for that person, no different from browsing the site; SecureCloudGroup hosts or redistributes nothing.", "status": "terms_allow", "terms_url": "https://new.mta.info/developers"}, "tier": "curated", "validation": {"bytes": 54813, "checked_at": "2026-09-28T04:10:37Z", "http": 200, "note": "protobuf 54813B", "robots": "allow", "status": "ok"}, "votes": {"no": 0, "yes": 0}} diff --git a/sourcetool/answers.py b/sourcetool/answers.py index aef821a..0fcd3cd 100644 --- a/sourcetool/answers.py +++ b/sourcetool/answers.py @@ -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"} @@ -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: {: {min?: , max?: }}` 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")] @@ -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"]: diff --git a/sourcetool/fills.py b/sourcetool/fills.py index 897f4db..46436b9 100644 --- a/sourcetool/fills.py +++ b/sourcetool/fills.py @@ -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")), diff --git a/tests/test_answers.py b/tests/test_answers.py index 9ea56d3..4ee3a66 100644 --- a/tests/test_answers.py +++ b/tests/test_answers.py @@ -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") diff --git a/tests/test_records_v12.py b/tests/test_records_v12.py index eab7ae0..dd6b6dc 100644 --- a/tests/test_records_v12.py +++ b/tests/test_records_v12.py @@ -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():