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
115 changes: 115 additions & 0 deletions .github/workflows/engine-constants.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: Engine Constants

# Keep engine constants out of the published docs.
#
# This repository is public and it serves docs.saturday.fit, so anything merged
# here is permanent: readable on the site, readable in every clone, and readable
# in the revision history afterwards. That makes the safety page a one-way door.
# The promises on it belong in public. Saturday bounds every calculation, the
# bounds are not partner-overridable, several of them scale with the athlete.
# The values it clamps to are a different thing, and a page that prints one has
# published a constant rather than a behaviour.
#
# WHY A GATE RATHER THAN CARE. A value lands on a page as a side effect of a PR
# about something else, written by someone doing the right thing about that
# other thing, and every reviewer reads past it because it is not what the PR is
# for. Care does not catch that. A check that runs on every PR does.
#
# The rules are shapes, not a list of values. A gate naming the constants would
# publish them in a public repository, which defeats itself, and it would only
# ever catch the ones somebody thought to write down. See the script header for
# what each rule sees and, more usefully, what it cannot.
#
# WHAT THIS DOES NOT REACH. Git history. A value removed today stays readable in
# the revision that carried it, and no CI check can change that. It also does
# not reach the other public surfaces: sweb's llms-*.md files on saturday.fit,
# the SDK repos, saturday-skills. Those need their own guards.
#
# Standard library python, no clone, no network, no node, no secrets: well under
# a second.
#
# RUNNER CHOICE, because someone will want to change it. This repository is
# public, and the organization's self-hosted runner group sets
# allows_public_repositories: false. That is the correct setting and it should
# stay: a public repository accepts pull requests from strangers, and a
# self-hosted runner would execute their code on the machine it runs on. So a
# job here that asks for self-hosted queues until it is cancelled, silently, and
# reports pending forever rather than failing. GitHub-hosted minutes are free for
# public repositories, which is why the other public repos in this organization
# run on ubuntu-latest too.

on:
pull_request:
branches: [main]
paths:
- '**.mdx'
- '**.md'
- '**.txt'
- 'engine-constants-allowlist.txt'
- 'scripts/check-engine-constants.py'
- '.github/workflows/engine-constants.yml'
push:
branches: [main]
paths:
- '**.mdx'
- '**.md'
- '**.txt'
- 'engine-constants-allowlist.txt'
- 'scripts/check-engine-constants.py'
workflow_dispatch:

concurrency:
group: engine-constants-${{ github.ref }}
cancel-in-progress: true

jobs:
engine-constants:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5

- name: No engine constants in published pages
run: python3 scripts/check-engine-constants.py --docs .

# Proves the gate still fails on what it was built to catch. A checker that
# silently stops matching reports the same clean run as a clean tree, which is
# the failure mode that makes a green CI worse than no CI.
#
# The fixture numbers are invented. It tests that each rule still fires on the
# SHAPE, and a fixture built from the real values would print them in a public
# file, which is the thing this whole gate exists to prevent.
self-test:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5

- name: The gate rejects a page carrying constants
run: |
set -euo pipefail
work=$(mktemp -d)
cp -r ./* "$work"/
cat >> "$work/guides/safety.mdx" <<'FIXTURE'

| Fluid | 4242 mL/hr | rate-adjacent, and nutrient-ceiling via "Fluid" |
| Sodium | Body weight in pounds times 77, up to 8888 mg/hr | imperial-mass |
Sodium scales at 99.5 mg per hour per kilogram of body weight.
Fluid tops out at 4242 per hour.
FIXTURE
if python3 scripts/check-engine-constants.py --docs "$work" > "$work/out.txt" 2>&1; then
echo "The gate passed a page full of engine constants. It is broken."
cat "$work/out.txt"
exit 1
fi
echo "Gate correctly rejected the fixture:"
cat "$work/out.txt"

for rule in imperial-mass rate-adjacent nutrient-ceiling body-weight-scaling; do
grep -aq " $rule " "$work/out.txt" || {
echo "Rule '$rule' did not fire on the fixture. It has stopped matching."
exit 1
}
done
echo "All four rules fired."
rm -rf "$work"
32 changes: 32 additions & 0 deletions engine-constants-allowlist.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Engine-constant allowlist, read by scripts/check-engine-constants.py.
#
# Every line records one number that is meant to be on a public page, and why.
# The gate is a shape rule, so it cannot tell a response field from a clamp; a
# human decides that here, in a file a reviewer reads.
#
# It MAY grow. Publishing a genuinely new response field is legitimate, and the
# cost of doing it is writing the argument down where someone can disagree.
# What does not belong is a value the engine clamps its own output to: no caller
# sets it, no response carries it, and the page can say what the limit does and
# what narrows it without saying what it equals.
#
# A line matching nothing FAILS. A suppression that suppresses nothing would sit
# there and silently absorb the next real one.
#
# Format: rule<TAB>path<TAB>match<TAB>why

rate-adjacent guides/safety.mdx 1500 mL/hr response field: max_safe_fluid_ml_per_hr arrives in the safety block of every response, teasers included, so a reader has it before they read this page
rate-adjacent guides/safety.mdx 3000 mg/hr response field: max_safe_sodium_mg_per_hr, same block, same reasoning
nutrient-ceiling guides/safety.mdx 1500 response field: the fluid advisory threshold again, on the line that states the warning it triggers
rate-adjacent guides/safety.mdx 350 mg/hr worked example of a teaser bucket, chosen well inside the range; shows the low bound is clamped rather than reading zero
nutrient-ceiling guides/safety.mdx 350 same example
nutrient-ceiling guides/safety.mdx 200 the low edge of the bucket that example renders
nutrient-ceiling guides/safety.mdx 500 the high edge of the bucket that example renders
rate-adjacent guides/athletes.mdx 150 g/hr request bound: carb_upper_limit_override is set BY the caller and rejected with a 400 outside its range, so a partner who does not know the range cannot debug the rejection
rate-adjacent guides/nutrition-calculation.mdx 30 g/hr teaser bucket width for carbohydrate, which any single teaser response reveals and a caller needs in order to render a range
rate-adjacent guides/nutrition-calculation.mdx 500 units/hr teaser bucket width for sodium and fluid, same reasoning
rate-adjacent guides/attribution.mdx 80 g/hr sample values inside an attribution display example; illustrative UI copy, not engine output
rate-adjacent guides/attribution.mdx 900 mL/hr same example
rate-adjacent guides/attribution.mdx 600 mg/hr same example
body-weight-scaling guides/athletes.mdx 20 request bound: weight_kg is set BY the caller and rejected with a 400 outside this range, so a partner who does not know the range cannot debug the rejection
body-weight-scaling guides/athletes.mdx 250 request bound: weight_kg is set BY the caller and rejected with a 400 outside this range, so a partner who does not know the range cannot debug the rejection
31 changes: 22 additions & 9 deletions guides/safety.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,22 +86,35 @@ The safety block is present on every prescription response, but not every endpoi

Saturday bounds every calculation. The limits are computed per athlete and per activity, then clamped by absolutes that no input combination can exceed.

| Guardrail | Absolute cap | How it narrows |
|-----------|--------------|----------------|
| Fluid | 1801 mL/hr | Thermal stress and intensity tighten the usable range well below the cap |
| Sodium | Body weight in pounds times 11, up to 5000 mg/hr | Carb experience, intensity, and thermal stress narrow the range |
| Sodium floor | Rises with thermal stress | Hot conditions cannot produce a low-sodium prescription |
| Carbohydrate | 150 g/hr | Dropped to 100 g/hr when the athlete is not prioritizing performance, then by `carb_upper_limit_override`, then by stated carb experience. An athlete who has never fueled above 30 g/hr is not handed a 120 g/hr target |
| Duration scaling | | Intake ranges are a function of duration, so a 6-hour target is not a 1-hour rate multiplied out |
| Guardrail | How it is bounded |
|-----------|-------------------|
| Fluid | A fixed ceiling no prescription crosses. Thermal stress and intensity tighten the usable range well below it |
| Sodium | A ceiling that scales with body weight, under a fixed absolute maximum. Carb experience, intensity, and thermal stress narrow the range |
| Sodium floor | Rises with thermal stress. Hot conditions cannot produce a low-sodium prescription |
| Carbohydrate | A ceiling narrowed by `carb_upper_limit_override`, by stated carb experience, and by whether the athlete is prioritizing performance. An athlete who has never fueled at high carbohydrate rates is not handed an aggressive target |
| Duration scaling | Intake ranges are a function of duration, so a 6-hour target is not a 1-hour rate multiplied out |

These apply on every calculation and are not partner-overridable. Note that the sodium cap is the one limit that scales with the athlete: a 70 kg athlete reaches it at roughly 1700 mg/hr, far below the 5000 mg/hr absolute.
These apply on every calculation and are not partner-overridable. That is deliberate. A caller able to raise a ceiling could raise it past what is safe for that athlete, and the athlete would have no way to know it happened. The ceilings are not uniform either: several scale with the athlete, so a smaller athlete is held to a tighter limit than a larger one on the same session.

<Note>
Teaser ranges are display buckets, and both ends are bounded. The upper bound never exceeds the caps above: a 1700 mL/hr fluid prescription renders `1300-1800`, not `1500-2000`. The lower bound never reads zero for a value the engine actually prescribed, so a 350 mg/hr sodium prescription renders `200-500` rather than `0-500`. A zero low bound means the prescribed value is genuinely near zero.
Teaser ranges are display buckets, and both ends are bounded. The upper bound is clamped to the engine's ceiling, so a bucket never implies an intake the engine would not prescribe. The lower bound never reads zero for a value the engine actually prescribed, so a 350 mg/hr sodium prescription renders `200-500` rather than `0-500`. A zero low bound means the prescribed value is genuinely near zero.

The bounds still describe a bucket rather than a target. Render the range as a range, and do not treat either endpoint as a recommended intake.
</Note>

### When a prescription reads lower than expected

A number that reads low usually has one of these causes:

- **The athlete capped themselves.** `carb_upper_limit_override` is a ceiling the athlete sets on their own carbohydrate, and the engine does not exceed it.
- **Stated carb experience is low.** An athlete who reports never having fueled at high carbohydrate rates is held below a target they have not trained for.
- **`performance` is `false`.** An athlete who is not prioritizing performance gets a lower carbohydrate ceiling.
- **Conditions are cool.** Low thermal stress pulls fluid and sodium down sharply. Overdrinking without sodium is the hyponatremia mechanism, so the engine is conservative in the cold by design.
- **The session is long.** Hourly rates are a function of duration rather than a constant, so a long session's per-hour target is not the short-session rate carried forward.
- **The profile is incomplete.** Check `precision.missing_fields`. A defaulted field widens the band and moves the number.

Sending the same activity with a fuller profile is the fastest way to tell a guardrail from a gap in the data.

## Confidence score

`confidence_score` (0.0-1.0) reports how much of the athlete's fueling profile was answered, derived from the width of the prescription band. A complete profile scores 1.0. It is a completeness signal, not a clinical risk score, and `precision.missing_fields` tells you which answers would raise it. See [Athlete Onboarding](/guides/onboarding).
Expand Down
Loading
Loading