diff --git a/.github/workflows/engine-constants.yml b/.github/workflows/engine-constants.yml new file mode 100644 index 0000000..d86ce7b --- /dev/null +++ b/.github/workflows/engine-constants.yml @@ -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" diff --git a/engine-constants-allowlist.txt b/engine-constants-allowlist.txt new file mode 100644 index 0000000..4a98063 --- /dev/null +++ b/engine-constants-allowlist.txt @@ -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: rulepathmatchwhy + +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 diff --git a/guides/safety.mdx b/guides/safety.mdx index 738524d..a0fe97f 100644 --- a/guides/safety.mdx +++ b/guides/safety.mdx @@ -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. - 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. +### 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). diff --git a/scripts/check-engine-constants.py b/scripts/check-engine-constants.py new file mode 100755 index 0000000..90fe4e4 --- /dev/null +++ b/scripts/check-engine-constants.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Keep engine constants out of the published docs. + +WHY THIS EXISTS. This repository is public and it serves docs.saturday.fit. +The pages describe what the prescription engine promises an athlete: that +limits apply on every calculation, that they scale with the athlete, that a +partner cannot raise them. Those promises are the point of the safety page and +they belong in public. The specific values the engine clamps to are a different +thing. They are the output of years of physiology work, they are not needed to +integrate against the API, and once printed they are permanent. + +The distinction this gate enforces, stated once so it does not have to be +rediscovered: + + A number the API hands the caller is documentation. max_safe_fluid_ml_per_hr + arrives in the safety block of every response including free teasers, so + printing it costs nothing and omitting it only makes the field mysterious. + + A number the caller must respect is a contract. carb_upper_limit_override + accepts a bounded range, and a partner who does not know the bound gets a 400 + with no way to discover why. + + A number the engine clamps its own output to is neither. No caller sets it, + no response carries it, and a page that prints it has published a constant + rather than a behaviour. + +WHAT IT CHECKS. Three shape rules, not a list of forbidden values. A gate that +named the constants would publish them in this file, which is self-defeating in +a public repository, and it would catch only the values someone thought to +write down. These rules catch a shape, so they also catch the constant nobody +anticipated. + + A imperial-mass lb, lbs, pound, pounds anywhere in a published page. The + public API is metric throughout: athletes carry weight_kg and nothing else. + Imperial mass appears in a partner-facing page for one reason, which is that + a body-weight coefficient is being spelled out. + + B rate-adjacent a number touching a nutrient rate unit: g/hr, mg/hr, mL/hr, + g/L, mg/L, units/hr, g/hour. + + C nutrient ceiling a line naming a nutrient AND a cap word AND carrying a + number of two or more digits. Catches a ceiling stated without a unit, which + rule B cannot see. The nutrient word is what keeps rate-limiting.mdx out of + it: a daily call ceiling is not a fueling ceiling. + + D body-weight scaling a line naming body weight AND carrying a number. Rule A + sees only the imperial phrasing of a per-kilogram or per-pound coefficient. + Restated in metric it carries no lb, no rate unit and no cap word, so nothing + above would notice it. + +WHAT A GREEN RUN DOES NOT PROVE. That the prose is right. These rules see +shapes, so a page describing a guardrail incorrectly in words passes cleanly, +the same blind spot check-docs-drift.py records for field names. It also cannot +reach git history, where a value removed today remains readable in the revision +that carried it. + +ALLOWLIST. engine-constants-allowlist.txt carries the matches that are meant to +be there, each with the reason it is meant to be there. Unlike the drift +baseline it may grow, because publishing a genuinely new response field is +legitimate. It costs a line naming the value and arguing for it in a public +file that a reviewer reads, which is the deliberation this gate exists to +force. An entry matching nothing FAILS, for the reason the drift baseline gives: +a suppression that suppresses nothing would silently absorb the next real one. + +Usage: + scripts/check-engine-constants.py [--docs ] [--list] +Exit: 0 clean, 1 findings. +""" +import argparse +import os +import re +import sys + +ALLOWLIST_NAME = "engine-constants-allowlist.txt" + +PUBLISHED_SUFFIXES = (".mdx", ".md", ".txt") + +# Not published, or published as machinery rather than prose. +SKIP_DIRS = {".git", "node_modules", ".claude", ".worktrees", "images", "scripts"} +SKIP_FILES = {ALLOWLIST_NAME, "README.md", ".mintignore"} + +RATE_UNITS = r"(?:g/hr|g/hour|mg/hr|mL/hr|ml/hr|g/L|mg/L|units/hr)" + +RULES = { + "imperial-mass": re.compile(r"\b(?:lbs?|pounds?)\b", re.IGNORECASE), + "rate-adjacent": re.compile(r"\d[\d,.]*\s?" + RATE_UNITS), +} + +NUTRIENT = re.compile(r"\b(?:carb|carbs|carbohydrate|sodium|fluid|hydration)\b", re.IGNORECASE) +CAP_WORD = re.compile( + r"\b(?:cap|caps|capped|ceiling|ceilings|absolute|maximum|upper limit|tops out|no more than)\b", + re.IGNORECASE, +) +TWO_DIGITS = re.compile(r"\d{2,}") + +# Rule A only sees the imperial phrasing of a body-weight coefficient. The same +# coefficient restated in kilograms carries no lb, no rate unit and no cap word, +# so nothing above would see it. This does. +BODY_WEIGHT = re.compile( + r"\b(?:body weight|weight in (?:kg|kilograms|pounds|lb)|weight_lb|weightLB)\b", + re.IGNORECASE, +) + + +def published_files(root): + """Every file a reader can reach, by browsing the site or cloning the repo.""" + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + for name in sorted(filenames): + if name in SKIP_FILES or not name.endswith(PUBLISHED_SUFFIXES): + continue + full = os.path.join(dirpath, name) + yield os.path.relpath(full, root), full + + +def scan(root): + """Return [(rule, path, line_no, match)], sorted and deduplicated per line.""" + found = [] + for rel, full in published_files(root): + with open(full, encoding="utf-8", errors="replace") as fh: + for line_no, line in enumerate(fh, 1): + for rule, pattern in RULES.items(): + for m in dict.fromkeys(x.group(0) for x in pattern.finditer(line)): + found.append((rule, rel, line_no, m.strip())) + if NUTRIENT.search(line) and CAP_WORD.search(line): + for m in dict.fromkeys(TWO_DIGITS.findall(line)): + found.append(("nutrient-ceiling", rel, line_no, m)) + if BODY_WEIGHT.search(line): + for m in dict.fromkeys(TWO_DIGITS.findall(line)): + found.append(("body-weight-scaling", rel, line_no, m)) + return sorted(found) + + +def load_allowlist(root): + """Return {(rule, path, match): why}. Format: rulepathmatchwhy.""" + path = os.path.join(root, ALLOWLIST_NAME) + entries = {} + if not os.path.exists(path): + return entries + with open(path, encoding="utf-8") as fh: + for line_no, raw in enumerate(fh, 1): + line = raw.rstrip("\n") + if not line.strip() or line.lstrip().startswith("#"): + continue + parts = line.split("\t") + if len(parts) < 4 or not parts[3].strip(): + sys.stderr.write( + "%s:%d malformed. Format: rulepathmatchwhy, " + "and why cannot be empty.\n" % (ALLOWLIST_NAME, line_no) + ) + sys.exit(1) + rule, rel, match, why = (p.strip() for p in parts[:4]) + entries[(rule, rel, match)] = why + return entries + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--docs", default=".", help="docs checkout to scan") + ap.add_argument("--list", action="store_true", help="print every allowed match too") + args = ap.parse_args() + + root = os.path.abspath(args.docs) + allowed = load_allowlist(root) + found = scan(root) + + unallowed = [f for f in found if (f[0], f[1], f[3]) not in allowed] + hit_keys = {(rule, rel, match) for rule, rel, _, match in found} + stale = sorted(k for k in allowed if k not in hit_keys) + + if args.list: + for rule, rel, line_no, match in found: + state = "allowed" if (rule, rel, match) in allowed else "FOUND" + print("%-7s %-16s %s:%d %s" % (state, rule, rel, line_no, match)) + print() + + for rule, rel, line_no, match in unallowed: + print("%s:%d %s %s" % (rel, line_no, rule, match)) + + if unallowed: + print() + print("%d engine constant(s) found in published pages." % len(unallowed)) + print() + print("Ask which kind of number this is:") + print(" the API returns it in a response -> allowlist it, and say so") + print(" a caller must respect it to avoid a 400 -> allowlist it, and say so") + print(" the engine clamps its own output to it -> it does not belong here.") + print(" Say what the limit does and what narrows it, not what it equals.") + print() + print("Allowlist format, in %s:" % ALLOWLIST_NAME) + print(" rulepathmatchwhy") + + for key in stale: + print("%s: allowlisted %s '%s' in %s matches nothing now. Delete the line." + % (ALLOWLIST_NAME, key[0], key[2], key[1])) + + if not unallowed and not stale: + print("Clean: %d allowed match(es), no engine constants." % len(found)) + + return 1 if (unallowed or stale) else 0 + + +if __name__ == "__main__": + sys.exit(main())