| spec_contextA | The standing brief: how to read results, and the measured findings that
make a number interpretable. Load this ONCE at the start of a conversation. Stable across runs — refetch only if told the spec changed.
|
| basic_metricsB | §6.0 the run's own description: average HR, three paces, windowed bests,
run/walk/stand time, and cadence. Args:
run: run_id, activity_id or Garmin file id. Empty means the latest run.
THESE ARE ANCHORS, NOT SIGNALS. For a run/walk athlete holding an HR cap,
average HR barely moves from run to run because HR is held nearly flat
across grade — quote it as the level that drift and efficiency are
deviations FROM, not as a finding.
Paces are never pooled: `overall` is Garmin's own basis (distance over
timer time), `running` and `walking` come from the rwd_* splits and are
separate quantities. A class below the minimum time is `gated` with its
time stated, not silently absent.
The bests are SLIDING WINDOWS, never an instantaneous maximum, and they are
NOT comparable across sampling regimes. Read each window's
`walk_time_fraction` and `flagged_record_fraction` before quoting it: a
maximum selects for positive measurement error in a way a mean does not.
Cadence is steps per minute. It is running-only and non-zero-only, so it
does NOT equal Garmin's own averageRunningCadence, which includes standing.
|
| max_hr_overA | The highest heart rate in a selection, and the run that produced it (§6.0). TWO CEILINGS, and the difference between them is the point.
`max_hr_recorded` is the single highest sample -- what the watch displayed,
and a SPIKE CANDIDATE, because a maximum selects for positive measurement
error. `max_hr_30s` is the highest 30 s rolling mean with §15.4-flagged
records excluded, and it is the defensible figure. When the two come from
DIFFERENT runs, the corpus's highest spike and its highest sustained effort
are not the same session, and `same_run` says so.
EACH WINNER CARRIES ITS CIRCUMSTANCES: date, run_id and dew point. **A
maximum found on a hot run is a different fact from one found on a hard
effort** -- neither disqualifies a peak, and both change what anchoring a
zone scale to that peak would mean. run_type is null and labelled "not
collected": this build has no source for it.
`declared_ceiling` compares the corpus peak against the max HR the athlete's
zones are actually anchored to (§14e). It is REPORTED, never corrected:
re-anchoring zones is an athlete decision with a coach behind it.
THE COMPARISON IS GATED ON THE SELECTION ITSELF. It is reported only when
a run IN THIS SELECTION reached within the ceiling band of the
demonstrated 30 s ceiling; otherwise it is REFUSED, naming the
selection's best 30 s value and the band. Over easy runs the gap measures
the selection, not the heart.
|
| hr_aboveA | Every contiguous excursion at or above a heart-rate threshold (§14e-ter). THE UNIT IS THE EXCURSION, NOT THE RECORD. A sustained bout would otherwise
arrive as hundreds of rows and a single spike as one, with nothing in the
shape to tell them apart — which is the whole question when deciding
whether a ceiling is demonstrated or merely recorded.
Each excursion carries the run and date, start offset, span, peak with its
timestamp, mean, the 30 s window CENTRED on the peak, record count,
§15.4 flag count, sampling regime, and pace / grade / cadence. **The same
peak reached while climbing at a walking pace and while running fast on
the flat are different facts about the same heart.**
RAW heart rate, never lag-shifted. FLAGS ARE REPORTED, NOT APPLIED: at the
top of the range a flag may mark a real artifact or a real spike and cannot
settle which. **A run whose peak has unresolved provenance (§14e-bis;
`unresolved_peak_run` in config/athlete.yaml, when set) is MARKED and never
dropped, and every total is reported a second time without it** under
`without_unresolved_peak`.
Args:
threshold: bpm. 0 (the default) means the configured threshold,
`hr_above_default_bpm` in config/athlete.yaml -- set it near the
top of your own HR distribution. `threshold_bpm` in the result
says which value was used.
|
| time_above_hrB | Time at or above each rung of the configured HR ladder
(time_above_ladder_bpm in config/athlete.yaml), per run and corpus-wide.
thresholds_bpm in the result lists the rungs used. THE QUESTION THE EXCURSION LIST CANNOT ANSWER: is the top of the range a
place this athlete has been REPEATEDLY, or twice? One run contributing six
excursions and six runs contributing one each look identical at the top of
a list sorted by peak.
Timer-time weighting, capped per record: time at a heart rate is a DOSE,
not a rate, so stopped time is not subtracted — the heart does not stop at
a road crossing. Totals are also reported without the run whose provenance
is open (§14e-bis).
|
| zone_by_gaitC | M7 time in HR zone split by GAIT — running vs walking vs standing. Zone-2 minutes are the headline, per run and rolled up per LOCAL ISO week.
WHY THE SPLIT MATTERS: a pooled zone-2 total mixes a walk up a climb
with running on the flat, and those are different training. On a hilly
run/walk session the walking share of zone 2 can exceed the running share
— invisible in the pooled figure, and the whole point of the run/walk
structure.
Boundaries come from EACH ACTIVITY'S OWN FILE where present (§14e); a
corpus spanning zone changes holds several distinct sets, and
`boundary_set` says which ruler a
run was measured with, and a week spanning two is reported as SPLIT rather
than summed into a number that is on no scale.
Do not compare this against Garmin's hrTimeInZone: ours excludes stops and
flagged records and applies the 40 s HR lag, so it reads lower by design.
|
| run_walk_cyclesB | M3 run/walk cycle economics — does the walk buy anything? (§17.2) Args:
run: run_id, activity_id or Garmin file id. Empty means the latest run.
CHOICE, NOT CAPACITY (§6.6). This is how the athlete elected to structure
the run; it says nothing about what they could have done.
`purchase` pairs each walk with the NEXT run block's pace; `decay` is run
pace by cycle index. Both are PAIRED SERIES, never a fitted slope — time
into the run confounds both, and a coefficient over four points is wider
than itself. Read `elapsed_at_*` before concluding the walk caused anything.
Run blocks under the 120 s pace gate report duration but no pace.
WHAT GOVERNED THE WALKS DECIDES WHAT THIS MEASURES, and this build does
not know. If a rule (an HR cap, a clock) decided them, purchase and decay
measure what the STRUCTURE delivered; if the athlete walked when they felt
like it, identical arithmetic measures WILLINGNESS. `walk_protocol` is
"not collected" here, so `poolable_with_other_runs` is false on every run:
structure that has not been established cannot be assumed. Read each run
on its own.
|
| session_seriesA | §17.1b the session as a 30 s binned series — for "over the run" reasoning. Args:
run: run_id, activity_id or Garmin file id. Empty means the latest run.
include_dynamics: add the per-bin running-dynamics block (below).
Default false: it is the bulk of the payload, and when false it is
absent from EVERY bin, with `dynamics_included: false` saying so.
`dynamics_available` is reported either way.
NULL-DISTANCE BINS (D4). A bin with fewer than two records that carry a
distance -- a GPS gap, an indoor stretch -- reports only the DISTANCE-
DERIVED fields null (distance_start_m/end_m, mps, pace_per_mile), with
`distance_reason` saying how many distance records it had. avg_hr,
steps_per_min, elevation_m, n_records and coverage are still reported
from the records the bin does hold. A bin with NO records at all (past
the last record, inside a long gap) is null throughout with n_records 0.
(A stationary ride is the opposite case: its odometer is a constant 0.0,
not null, so its bins report mps 0.0 -- a known defect, CLAUDE.md §18.)
Pace, HR, elevation, cadence and run/walk/stand state per bin, indexed by
BOTH elapsed seconds and path distance, so "at minute 40" and "at mile 4"
are both answerable from one payload.
STATE IS SECONDS, NOT A LABEL. Each bin carries `run_s`, `walk_s` and
`stand_s`. Many run/walk walks are shorter than a 30 s bin, so a majority
label would erase much of the walking — and on a run/walk session a large
share of bins contain both running and walking.
Elevation is DEM-gated and absent on more than half the corpus; read
`elevation_available`. No position is ever emitted.
RUNNING DYNAMICS PER BIN (§17.1b-bis): stance_time_ms, step_length_mm,
vertical_oscillation_mm and vertical_ratio_pct, each {value, n, unit}.
Garmin's units, unchanged -- millimetres, milliseconds, percent -- and
vertical_ratio is the DEVICE'S OWN field, never recomputed from the other
two.
rwd_run RECORDS ONLY, which is a DIFFERENT RECORD SET from the one behind
`steps_per_min` in the same bin (all records with cadence > 0, running and
walking pooled). `filters.gait_conventions` states each field's, because
reading them as one gait is the mistake the two conventions invite.
n IS PER FIELD. The device drops the four independently -- one field can
sit on a handful of records where another sits on thousands -- so a
per-bin n would misstate the evidence behind a field by orders of
magnitude.
THREE ABSENCES, NEVER 0.0, all distinguishable: `dynamics_reason`
"no_device_channel" (this activity carries none -- FR245, phone file, or a
non-running sport), "no_running_in_bin" (the device records them, this bin
has no running), or null with n 0 (running present, device wrote nothing
for that field here).
`flags_available` says whether §15.4 flags could be applied. Where
records_enriched covers the run, flagged records are excluded; where it
does not, every record is included -- the same figure with and without
flags is NOT the same quantity, so the payload says which it is rather
than refusing the run.
|
| mile_splitsA | Per mile (or unit="quarter"): running-only pace, HR, DEM gain/loss
and time by gait, with Garmin's own laps beside it for cross-check. Args:
run: run_id, activity_id or Garmin file id. Empty means the latest run.
unit: "mile" (default) or "quarter".
BOUNDARIES from the device odometer, interpolated exactly at each mile
(the windowed-best rule), never from 30 s bins; across a standing stretch
the LAST instant at that distance. The final unit is a PARTIAL with its
real length. A unit crossing a recording gap over 20 s is FLAGGED
(`contains_gap` / `boundary_in_gap`), not refused.
RUNNING-ONLY PACE: rwd_run splits intersected with the unit, stopped time
removed. A walk break inside a mile is excluded from pace and counted in
`time_walking_s`. `hr_running` (PRIMARY) is time-weighted over the same
running overlap; `hr_all_gaits` covers the whole unit. Both RAW, no lag.
Gain/loss from the smoothed DEM profile, in m and ft; with no DEM they are
null and `elevation_reason` says why. `garmin_laps` are Garmin's laps AS
RECORDED -- only distance-triggered ones are miles, and their pace
includes walking -- and are never substituted for ours.
|
| hill_paceB | §6.0g pace on every slope effort of one run, split by direction. Args:
run: run_id, activity_id or Garmin file id. Empty means the latest run.
Each effort carries mean pace (whole effort, summit to col, moving time)
AND best 30 s (the part actually run fast). They are different quantities —
a slow mean beside a much faster best 30 s is a description of how the
effort was run (walked in part, run in part), not a contradiction.
READ `comparable_ground.length_ratio` BEFORE COMPARING THE DIRECTIONS.
Ascents and descents need not cover the same ground — plateau absorption
can glue flat ground onto one direction (§14k) — so part of
any ascent-vs-descent difference is terrain rather than the athlete.
|
| cadence_by_gradeB | §6.0 cadence per 5% grade bin over a selection, split by split_type. Never pooled across run and walk — running and walking cadence sit tens of
spm apart, so a pooled bin reports a number neither mode took.
Needs a DEM tile for the grade, so it sees only DEM-covered runs;
`n_runs_without_grade` says how many of the selection had none.
|
| drift_onsetB | §6.9 WHEN in a run cardiac drift begins — a VIEW of §6.7, currently REFUSED. Args:
run: run_id, activity_id or Garmin file id. Empty means the latest run.
The result carries `gate`, which is computed from the live corpus. While
`gate.reportable` is false the curve is a DIAGNOSTIC and must not be quoted
as a finding — the same standing as tau (§6.3). Read `gate.reason`: it says
exactly what would make it start reporting.
Onset is confounded with run length and its earliest representable value is
§6.7's own 600 s minimum pair separation. Dew point travels with every
value because onset is uninterpretable without it.
|
| reportC | The §16.2 post-run report for one run, against its recent history. Args:
run: run_id, activity_id or Garmin file id. Empty means the latest run.
Presents only — it does not judge. Eliminating causes is your work.
Read `notable` first, then `unenforced`.
|
| session_loadA | Foster session load: Garmin's own post-run RPE x timer minutes, in AU. Args:
run: run_id, activity_id or Garmin file id -- ONE activity. Empty,
with no selection filter, means the latest run.
start_date, end_date, cluster, last_n: a SELECTION instead of one
activity -- of EVERY SPORT, since load is a dose and a rated
ride is load the legs carried (`summary.n_by_sport`). Name a run
OR a selection, never both.
run_type: REJECTED when non-empty -- this build has no run_type source.
THE RPE IS GARMIN'S. Some watches ask "how hard was that?" when an
activity is saved; an activity where the device did not ask, or the
rating was declined, is `unavailable` with that reason -- NEVER priced at
0 and never filled from another activity. `garmin_feel` is SHOWN and
never used: it is a different question, and its 1-5 direction is
unverified.
TIMER TIME, NOT MOVING TIME: load is a session DOSE, not a rate, so
standing time counts. Read `filters` for the formula and its basis.
OVER A SELECTION, `summary.sum_foster_load_au_priced` sums the priced
sessions. When any session is unpriced -- unrated (`n_unrated`), or rated
with no timer time (`n_rated_unpriced`, each with its reason) -- it is a
LOWER BOUND and `is_lower_bound` says so; unpriced sessions are left out,
never counted as zero. No GPS coverage gate applies -- load is volume,
not a terrain comparison.
|
| list_runsA | Corpus listing, newest first. All filters optional and AND-ed. Args:
start_date: inclusive YYYY-MM-DD.
end_date: inclusive YYYY-MM-DD.
run_type: REJECTED when non-empty -- this build has no run_type
source. Each row reports run_type null, labelled "not collected";
a later version re-enables it through user-defined run types.
cluster: a cluster name from config/athlete.yaml, or NO_GPS / OTHER.
limit: maximum rows.
sport: empty means running. "any" lists every sport; otherwise one
sport by name (cycling, hiking, ...).
Each row carries `local_date`, the day the athlete would call it.
|
| matched_terrain_driftA | §6.7 HR drift on the SAME ground, bpm/hour, over a selection of runs. Pairs RECORDS within 20 m of each other, at least 10 min apart, BOTH
rwd_run, with speed matched to 0.35 m/s. Walking is not paired. Read
`applied_filters` — those tolerances are what the number means.
Effort-matched by construction, so it is the substitute when §6.4's
steady-effort gate fails. Read `excluded` too — a drift figure over "five
long runs" means something different if two were dropped for coverage.
`segment_effort_drift` is a DIFFERENT quantity, usually several times
larger. Neither is "the" drift.
|
| segment_effort_driftA | Effort-level HR drift on a matched slope. NOT §6.7 — a DIFFERENT quantity. Pairs whole effort averages with no speed match and no split_type filter,
and normalises by the gap between effort starts, so a short gap amplifies:
+10 bpm over 10 min reads as +60 bpm/hour. On the same run it can read
several times larger than matched_terrain_drift.
Use `matched_terrain_drift` unless you specifically want the slope-matched
measure. NEVER present either as "the" drift. Read `applied_filters`.
|
| efficiency_factorC | §6.8 speed per heartbeat on segment-matched terrain, over a selection. Matched by segment identity, NOT by GAP — GAP is unvalidated (§12.2). |
| delta_hrC | §6.3 HR excursion across an effort, by direction, over a selection. Ascent and descent are NOT comparable on this metric: the excursion
differs systematically by direction. Tau is never returned by this tool;
whether it is reportable at all is §6.3's corpus-level gate.
|
| grade_binned_paceB | §6.2 pace by grade bin, split by split_type, over a selection. Run and walk are never pooled: pooling reads as "pace does not vary with
grade" purely because each bin mixes the two modes.
|
| segment_historyC | §6.6 one slope, every traversal, over time — the trending primitive. Use `report` to find segment ids; they are unrecognisable on their own, so
the report lists them in the order the reference run meets them.
|
| list_segmentsC | The slope library: every segment, with where the reference run meets it. |
| ride_blocksB | §18 the stationary ride's blocks, each with its HR summary. The watch records HEART RATE ONLY for these -- measured on the indoor
cycling activities in the corpus: no cadence, no speed, no position, no power,
and distance_m is a constant 0.0 rather than null. So resistance, RPM and
the block boundaries are ATHLETE-RECALLED, and `provenance` says so on
every payload. `machine_totals` is the console readout -- machine-reported,
a different reliability, and the machine is the only source for it.
A block containing an off-bike event (water bottle, restroom) is FLAGGED:
the clock keeps running, so the break lands inside a block and its HR is
recovery rather than work at that resistance.
An unannotated ride returns `unavailable` WITH A REASON, never an empty
list -- nothing logged is a different fact from a ride with no structure.
|
| resistance_historyB | §18.1 HR at matched resistance over CALENDAR TIME — the longitudinal cut. EFFECTIVE n IS THE NUMBER OF BLOCKS, NEVER THE NUMBER OF HR RECORDS. A
block is one exposure to one resistance; its several hundred HR samples are
one observation of it. This project has made the opposite mistake twice
(§6.9, §2.6) and both are recorded as the same disease.
A group with fewer than three blocks reports `trend_status: insufficient`
and says how many it has, rather than drawing a line through two points.
Blocks with no stated RPM are grouped SEPARATELY from blocks that have one:
no stated cadence is not a cadence that happens to match.
|
| time_in_resistanceB | §18.2 minutes per resistance value and per 0.5-turn band, per LOCAL week. The same shape as time-in-zones, and the only ride read that rests on NO
assumption beyond the blocks having been logged correctly. Pure
aggregation of stored blocks. |
| save_ride_blocksA | Store one stationary ride's block structure. THE ONE tool that writes
athlete input. A writer is a deliberate act: the watch cannot record resistance or RPM,
and nothing else in this system does either, so there is no existing
channel this competes with. Perceived effort is NOT a field here --
Garmin's own self-evaluation, where the device asked for one, is the
effort channel (see session_load), and a second would be two places for
one answer.
Args:
payload: a JSON object. Required: run_id (or activity_id), raw_text,
blocks. Optional: events, machine_totals.
supersede: replace an existing log for this ride, AS AN ARGUMENT
BESIDE the payload and never a field inside it. The previous
revision is KEPT.
BLOCKS ARE END-ANCHORED: each entry is {resistance, end_min, rpm?}, and
start_min is the previous entry's end (0 for the first). Supplying
start_min is REJECTED -- one way to state a boundary means no way for two
statements to disagree. Storage is explicit [start_min, end_min).
resistance is DECIMAL knob-turns from closed (quarter turns such as 4.25,
4.5, 4.75 are ordinary values), validated against [0, MAX]. That ceiling is
DECLARED, not
measured, until the athlete tests where the knob stops.
rpm is a number or [low, high] and is stored as [low, high], NEVER
collapsed to a midpoint -- a midpoint invents precision the recollection
does not have. Absent is absent; a log with no RPM at all is complete.
raw_text is REQUIRED and stored VERBATIM through every revision. The
parsed blocks are an interpretation of it; the text is the record, for the
same reason bronze keeps the raw file.
AMBIGUITY IS REFUSED, NOT RESOLVED. This tool takes already-structured
blocks; overlapping or contradictory entries are rejected naming both.
Parsing prose is the chat's job, at save time, with the athlete present.
Athlete-recalled average HR is NOT a field here -- the watch's HR is
authoritative and already in the corpus. It survives in raw_text only.
|
| syncA | Fetch new Garmin activities and process them. Returns a JOB ID at once. This takes minutes and holds the database's exclusive write lock while it
runs, so it cannot be awaited: a synchronous version would make every other
tool call fail for the duration. Poll `sync_status` instead.
NEW FILES ONLY. Downloads APPEND to data/raw/; nothing in this path
modifies, moves, renames or deletes an existing raw file (§3).
A FILE THAT DID NOT INGEST IS REPORTED BY NAME. A finished job with
`clean: false` lists each rejected file under `rejected` with the reason
and a `next_step` carrying the exact command: a recording with a drive or
a forgotten stop at one end is cleared by confirming the window that is
the run (`confirm-file <garmin-id> --end '...'`) and syncing again.
Confirming a window is CLI-ONLY -- no tool here does it -- so relay the
command to the athlete rather than looking for one.
A STEP THAT FAILS COSTS ONLY ITSELF. Each step's outcome is in `steps`
(done / skipped / failed); a failure is in `step_errors` and `problems`
by step name and makes the job `clean: false`, and the rejections are
still reported. With no DEM tiles on disk the terrain steps (dem, silver,
match, recovery) are SKIPPED with the reason in `skipped_steps` -- the
normal state of a fresh install, not a failure.
|
| sync_statusA | Progress of a sync job. Empty job_id returns the most recent. States: queued, running (with the current step), done, failed (with which
step failed -- a Garmin outage and a corrupt file are different problems).
A job fails outright only when the DOWNLOAD fails; a later step that
raises is reported inside a `done` job's result, by name, under
`step_errors` with `clean: false`, so the rejections still arrive.
|
| pingA | Is the server alive, which code is it running, is the database locked. NEVER TOUCHES THE DATABASE, so it answers during a sync. A `broken` from
any other tool while `write_locked` is true is the lock, not the corpus.
|