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)