From 6642ba349e0a99463572839b57a292012850be8b Mon Sep 17 00:00:00 2001 From: Alex Harrison Date: Thu, 13 Aug 2026 06:13:38 -0600 Subject: [PATCH] onboarding: stop publishing how band width is measured, and gate the example The precision page told a public reader that band width is measured by running the engine against each missing field at its plausible extremes, and printed a worked band_impact of 0.4 g/hr, 86.1 mg/hr, 79.0 mL/hr. Those two together are an inversion: the reported number is a half-width scaled by a constant and divided by the caller's own duration, so a reader recovers the engine's output difference between that field's two extremes, at an operating point they chose, for fourteen axes in one response. Measured against the engine: recovery error zero on all fourteen. The page now says what the field is for, which is the collection order, and the example carries values on the step the bands themselves render on. Nothing a partner builds against changes: the order is unchanged, display_label, required, message and onboarding.url are unchanged. Two confidence-score sentences said the score is derived from band width. That is an implementation detail and it is not what production computes. Both now state the observable property, which holds either way: a complete profile scores 1.0, unanswered fields lower it, missing_fields says which answers raise it. One justified the required/recommended split by claiming the recommended fields move the numbers less. That is a sensitivity ranking. It now states the classification and the fact that exactness needs all of them. check-engine-constants.py gains an impact-resolution rule. Its existing rate-adjacent rule looks for a printed unit next to a number, so it cannot see a JSON response body, where the value follows the key and the key spells its unit in underscores. That blind spot covered every response example on the site, which is where this got in. The new rule is deliberately narrow: only values inside a band_impact object, and only when they are off the band's own step. A sample prescription elsewhere in an example stays what this file already calls it, a number the API hands the caller. Verified: check-engine-constants.py, this tree exit 0, 19 allowed, no findings check-engine-constants.py, prose reverted, gate kept exit 1, 3 findings, all onboarding.mdx:23 check-engine-constants.py, on-grid 100 seeded to 86.1 exit 1, 1 finding, so the rule is not vacuous check-docs-drift.py --backend fuel-backend exit 0, 116 spec operations, 16 webhook events Ci-From: linux Ci-Session: 87067284-6412-4360-a52c-38cf35d2bfbf --- guides/nutrition-calculation.mdx | 2 +- guides/onboarding.mdx | 8 ++--- guides/safety.mdx | 2 +- scripts/check-engine-constants.py | 58 +++++++++++++++++++++++++------ 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/guides/nutrition-calculation.mdx b/guides/nutrition-calculation.mdx index 2fcb40e..67fd385 100644 --- a/guides/nutrition-calculation.mdx +++ b/guides/nutrition-calculation.mdx @@ -123,7 +123,7 @@ When you include an `athlete_id`, Saturday reads that athlete's stored profile: ### Confidence score -The `safety.confidence_score` (0.0-1.0) is derived from how wide the prescription band is: a complete fueling profile scores 1.0, and every unanswered field widens the band and lowers the score. `precision.missing_fields` tells you which answers would raise it. +The `safety.confidence_score` (0.0-1.0) reports how much of the athlete's fueling profile was answered: a complete fueling profile scores 1.0, and every unanswered field lowers the score. `precision.missing_fields` tells you which answers would raise it. | Range | Meaning | |-------|---------| diff --git a/guides/onboarding.mdx b/guides/onboarding.mdx index b0416e7..938436b 100644 --- a/guides/onboarding.mdx +++ b/guides/onboarding.mdx @@ -20,7 +20,7 @@ Calculation responses carry a `precision` object on every tier: "missing_fields": [ { "field": "sweat_level", "required": true, "display_label": "how much you sweat", - "band_impact": { "carb_g_per_hr": 0.4, "sodium_mg_per_hr": 86.1, "fluid_ml_per_hr": 79.0 } } + "band_impact": { "carb_g_per_hr": 0, "sodium_mg_per_hr": 100, "fluid_ml_per_hr": 100 } } ], "message": "For exact numbers, a few key details are still needed: how much you sweat. Each one narrows the range.", "onboarding": { @@ -32,10 +32,10 @@ Calculation responses carry a `precision` object on every tier: ``` - **`profile_complete: true`** puts exact numbers in the response (`carb_g_per_hr` and friends). -- **`profile_complete: false`** puts bands there instead (`carb_range_g_per_hr` and friends). Band width is measured by running the engine against each missing field at its plausible extremes, so `missing_fields` sorted most-impactful-first is your collection roadmap: the top entry is the question that buys the most precision. +- **`profile_complete: false`** puts bands there instead (`carb_range_g_per_hr` and friends). `missing_fields` arrives sorted most-impactful-first, so it is your collection roadmap: the top entry is the question that buys the most precision. - The trial clock starts at the athlete's first calculation carrying any real data. Zero-data calls never start it. Collect the profile first and the 30-day window delivers exact numbers rather than wide bands. See [Freemium Model](/guides/freemium-model#30-day-full-precision-trial). -Each missing field carries a `display_label`, the plain-English name of the question ("how much you sweat"), so you can build the prompt without exposing an internal key. `precision.message` is written for an athlete to read and is safe to surface directly. `band_impact` is in per-hour units, and the numbers say how much narrower the band gets when that one field is answered. +Each missing field carries a `display_label`, the plain-English name of the question ("how much you sweat"), so you can build the prompt without exposing an internal key. `precision.message` is written for an athlete to read and is safe to surface directly. `band_impact` is in per-hour units, rounded to the same increments the bands use, and says roughly how much narrower the band gets once that field is answered. Take `missing_fields` in the order it arrives rather than re-sorting on these numbers, since rounding can leave two fields tied. ### What "complete" means, twice @@ -46,7 +46,7 @@ Two different completeness checks share a name, and mixing them up is the usual The athlete's own `profile_complete` field, the one `?profile_complete=false` filters on and `athlete.profile_completed` fires for, covers the stored profile only: `sex`, `year_of_birth`, `weight_kg`, `sweat_level`, `saltiness`, `satiety_level`, `fitness_level`, `carb_experience`, `usual_carb_consumption`, and an answered concerns question. -Of the profile fields, `sex`, `year_of_birth`, `weight_kg`, `sweat_level`, `saltiness`, `carb_experience`, and `usual_carb_consumption` are the safety core, and `missing_fields` marks them `required: true`. `satiety_level`, `fitness_level`, and `concerns` come back as `required: false` because each one moves the numbers less, but exactness needs all of them. +Of the profile fields, `sex`, `year_of_birth`, `weight_kg`, `sweat_level`, `saltiness`, `carb_experience`, and `usual_carb_consumption` are the safety core, and `missing_fields` marks them `required: true`. `satiety_level`, `fitness_level`, and `concerns` come back as `required: false`, and exactness needs all of them. ## Four ways to collect the profile diff --git a/guides/safety.mdx b/guides/safety.mdx index a0fe97f..d4ea40a 100644 --- a/guides/safety.mdx +++ b/guides/safety.mdx @@ -117,7 +117,7 @@ Sending the same activity with a fuller profile is the fastest way to tell a gua ## 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). +`confidence_score` (0.0-1.0) reports how much of the athlete's fueling profile was answered. 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). | Confidence range | Meaning | Your display | |-----------------|---------|--------------| diff --git a/scripts/check-engine-constants.py b/scripts/check-engine-constants.py index 90fe4e4..0d65ed0 100755 --- a/scripts/check-engine-constants.py +++ b/scripts/check-engine-constants.py @@ -38,6 +38,18 @@ B rate-adjacent a number touching a nutrient rate unit: g/hr, mg/hr, mL/hr, g/L, mg/L, units/hr, g/hour. + B2 impact-resolution a band_impact value that is not a multiple of the step + its band renders on: 10 g/hr, 100 mg/hr, 100 mL/hr. A sample prescription in + a response example is a number the API hands the caller, which this file + already calls documentation. band_impact is not that. It is the engine's own + output difference between one field's two probe extremes, divided by twice + the caller's duration and scaled by a constant, so a reader with two + responses at different durations inverts it back to a first difference of + the engine along an axis they chose. Printing it on the band's own step + keeps the example honest and keeps the finer number off a public page. + Rule B cannot see any of this: the value follows the key, and the key spells + its unit in underscores. Found live in the onboarding example. + 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 @@ -86,6 +98,20 @@ "rate-adjacent": re.compile(r"\d[\d,.]*\s?" + RATE_UNITS), } +# A per-hour field name spells its unit in underscores and the value follows the +# key, so RATE_UNITS sees neither. Only band_impact values are checked; a sample +# prescription elsewhere in a response example is a number the API hands the +# caller, which this file already calls documentation. +BAND_IMPACT = re.compile(r"band_impact", re.IGNORECASE) +IMPACT_VALUE = re.compile( + r"\"?(?P[a-z]+_(?:g|mg|ml)_per_hr)\"?\s*[:=]\s*(?P-?\d+(?:\.\d+)?)" +) +# The steps the bands themselves render on (bandRoundCarb, bandRoundSodium, +# bandRoundFluid in fuel-backend/pkg/api/precision.go). +IMPACT_GRID = {"g": 10.0, "mg": 100.0, "ml": 100.0} +# A JSON band_impact object may wrap; keep looking this many lines past the key. +IMPACT_WINDOW = 6 + 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", @@ -118,16 +144,28 @@ def scan(root): 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)) + lines = fh.readlines() + impact_left = 0 + for line_no, line in enumerate(lines, 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)) + if BAND_IMPACT.search(line): + impact_left = IMPACT_WINDOW + if impact_left: + impact_left -= 1 + for m in IMPACT_VALUE.finditer(line): + unit = m.group("key").rsplit("_per_hr", 1)[0].rsplit("_", 1)[-1] + grid = IMPACT_GRID.get(unit) + val = float(m.group("val")) + if grid and abs(val - round(val / grid) * grid) > 1e-9: + found.append(("impact-resolution", rel, line_no, m.group(0).strip())) return sorted(found)