diff --git a/submissions/numeric-sanity-checker/README.md b/submissions/numeric-sanity-checker/README.md new file mode 100644 index 00000000..43f20af1 --- /dev/null +++ b/submissions/numeric-sanity-checker/README.md @@ -0,0 +1,41 @@ +# Numeric Sanity Checker + +Catches the three arithmetic mistakes that show up constantly in generated +reports: a budget breakdown whose parts don't actually sum to the stated +total, a percentage distribution that doesn't sum to 100, and a percent-change +figure that's actually just the point difference between two percentages +mislabeled as a percent change. + +## How it works + +`scripts/check_numbers.py` takes a small JSON list of checks and verifies +each one exactly: sum a list of parts against a claimed total, sum a list of +percentages against an expected whole, or recompute a percent-change figure +from its two source values. Rounding tolerance is built in so normal +report-rounding doesn't get flagged as an error. + +## Usage + +```bash +python scripts/check_numbers.py checks.json +echo '[{"type": "sum", "parts": [10,20,30], "claimed_total": 61}]' | python scripts/check_numbers.py - +``` + +No dependencies beyond the Python standard library. + +## Why percent change specifically + +Going from 20% to 25% is a 5 percentage-point increase, but a 25% relative +increase, and mixing the two up is one of the most common numeric errors in +generated business writing. This skill's checker computes the actual formula, +`(new - old) / old * 100`, and flags a claimed figure that doesn't match it. + +## Limits + +This verifies arithmetic consistency between numbers already given, not +whether the underlying source numbers are correct. Garbage inputs that are +internally consistent will still pass. + +--- + +Skill by Tim Karlsson (╯°□°)╯︵ ┻━┻ Works 60% of the time, every time. diff --git a/submissions/numeric-sanity-checker/SKILL.md b/submissions/numeric-sanity-checker/SKILL.md new file mode 100644 index 00000000..c66cb5ab --- /dev/null +++ b/submissions/numeric-sanity-checker/SKILL.md @@ -0,0 +1,71 @@ +--- +name: numeric-sanity-checker +description: >- + Use this skill before presenting any report, summary, or answer containing + calculated numbers, such as subtotals that should add to a total, + percentages that should sum to 100, or a stated percent change between two + figures, to catch arithmetic errors before they ship. +--- + +Verify the arithmetic actually holds before presenting numbers as correct. + +## Instructions + +1. This applies whenever a response includes: parts that are claimed to sum + to a total (a budget breakdown, a category split), percentages that are + claimed to sum to a whole (a distribution, a survey breakdown), or a + percent-change figure derived from two values (growth, decline, a + before/after comparison). + +2. Before presenting the numbers, run the bundled checker when a Python + environment is available: + + ```bash + python scripts/check_numbers.py checks.json + ``` + + where `checks.json` is a list of check objects, one of: + + ```json + {"type": "sum", "parts": [10, 20, 30], "claimed_total": 61} + {"type": "percentages", "values": [40, 35, 26], "expected_total": 100} + {"type": "percent_change", "old": 80, "new": 100, "claimed_pct": 20} + ``` + + It also reads from stdin. Without Python available, do the same three + checks by hand: add the parts, add the percentages, and recompute percent + change as `(new - old) / old * 100`, not the raw point difference between + two percentages. + +3. If a check fails, don't just flag it. Recompute the correct value and fix + the number before presenting it, or if the discrepancy might mean the + underlying data (not the arithmetic) is wrong, say so and ask rather than + silently substituting a number. + +4. A very common specific mistake worth naming: percent change is not the + same as the point difference between two percentages. Going from 20% to + 25% is a 5 percentage-point increase, but a 25% relative increase. State + which one is actually meant, and compute the one that's stated. + +5. Rounding is expected and not itself an error. Percentages that sum to + 99.9 or 100.1 due to rounding are fine; the checker's default tolerance + accounts for that. A sum that's off by a meaningful amount is the actual + target. + +## Guardrails + +- Never present a subtotal, percentage breakdown, or percent-change figure + without having verified the arithmetic, either through the script or by + hand. +- Never silently adjust the underlying numbers to make the arithmetic work. + If parts don't sum to a stated total, either the parts, the total, or one + input is wrong; say which, don't quietly fudge one number to force + agreement. +- Don't apply this as a broad fact-checking pass. It verifies arithmetic + consistency between numbers already given, not whether the source numbers + themselves are accurate. + +## Tone + +Quick and matter-of-fact. Fix the number and move on; no need to narrate a +successful check, only a failed one. diff --git a/submissions/numeric-sanity-checker/metadata.json b/submissions/numeric-sanity-checker/metadata.json new file mode 100644 index 00000000..303871e0 --- /dev/null +++ b/submissions/numeric-sanity-checker/metadata.json @@ -0,0 +1,11 @@ +{ + "name": "Numeric Sanity Checker", + "description": "Verifies the arithmetic in a report before it ships: do subtotals sum to the stated total, do percentages sum to 100, does a percent-change figure actually match the two numbers it's derived from.", + "platforms": ["Cowork", "Copilot Studio", "Scout"], + "tags": ["accuracy", "data", "quality", "scripts", "guardrail", "reporting"], + "author": "Tim Karlsson", + "authorUrl": "https://github.com/Timziito", + "version": "1.0.0", + "createdAt": "2026-07-27", + "updatedAt": "2026-07-27" +} diff --git a/submissions/numeric-sanity-checker/scripts/check_numbers.py b/submissions/numeric-sanity-checker/scripts/check_numbers.py new file mode 100644 index 00000000..7faf7b5f --- /dev/null +++ b/submissions/numeric-sanity-checker/scripts/check_numbers.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Verify the arithmetic in a numeric report before it ships. + +Catches the three most common numeric slips in generated reports: parts that +don't actually sum to the stated total, percentages that don't sum to the +stated whole, and a percent-change figure that doesn't match the two numbers +it's supposedly derived from. Deterministic only. Whether the underlying +numbers themselves are correct is not this script's job, only whether the +arithmetic between the numbers given is consistent. + +Usage: + python scripts/check_numbers.py checks.json + echo '[{"type": "sum", ...}]' | python scripts/check_numbers.py - + +Input is a JSON list of check objects: + + {"type": "sum", "parts": [10, 20, 30], "claimed_total": 61, "tolerance": 0.01} + {"type": "percentages", "values": [40, 35, 26], "expected_total": 100, "tolerance": 0.5} + {"type": "percent_change", "old": 80, "new": 100, "claimed_pct": 20, "tolerance": 0.5} + +"tolerance" is optional on every check type; sensible defaults are used if +omitted (rounding in a report is normal and not itself an error). +""" + +from __future__ import annotations + +import argparse +import json +import sys + +DEFAULT_SUM_TOLERANCE = 0.01 +DEFAULT_PERCENTAGE_TOLERANCE = 0.5 +DEFAULT_PERCENT_CHANGE_TOLERANCE = 0.5 + + +def check_sum(parts: list[float], claimed_total: float, tolerance: float = DEFAULT_SUM_TOLERANCE) -> dict: + actual = sum(parts) + diff = actual - claimed_total + return { + "type": "sum", + "ok": abs(diff) <= tolerance, + "actual": actual, + "claimed": claimed_total, + "diff": diff, + "detail": f"parts sum to {actual}, claimed total is {claimed_total}", + } + + +def check_percentages(values: list[float], expected_total: float = 100, tolerance: float = DEFAULT_PERCENTAGE_TOLERANCE) -> dict: + actual = sum(values) + diff = actual - expected_total + return { + "type": "percentages", + "ok": abs(diff) <= tolerance, + "actual": actual, + "claimed": expected_total, + "diff": diff, + "detail": f"percentages sum to {actual}, expected {expected_total}", + } + + +def check_percent_change(old: float, new: float, claimed_pct: float, tolerance: float = DEFAULT_PERCENT_CHANGE_TOLERANCE) -> dict: + if old == 0: + return { + "type": "percent_change", + "ok": False, + "actual": None, + "claimed": claimed_pct, + "diff": None, + "detail": "old value is 0; percent change is undefined, don't state one", + } + actual = (new - old) / old * 100 + diff = actual - claimed_pct + return { + "type": "percent_change", + "ok": abs(diff) <= tolerance, + "actual": round(actual, 4), + "claimed": claimed_pct, + "diff": round(diff, 4), + "detail": f"({new} - {old}) / {old} * 100 = {round(actual, 4)}, claimed {claimed_pct}", + } + + +CHECKERS = { + "sum": lambda c: check_sum(c["parts"], c["claimed_total"], c.get("tolerance", DEFAULT_SUM_TOLERANCE)), + "percentages": lambda c: check_percentages(c["values"], c.get("expected_total", 100), c.get("tolerance", DEFAULT_PERCENTAGE_TOLERANCE)), + "percent_change": lambda c: check_percent_change(c["old"], c["new"], c["claimed_pct"], c.get("tolerance", DEFAULT_PERCENT_CHANGE_TOLERANCE)), +} + + +def run(checks: list[dict]) -> list[dict]: + results = [] + for check in checks: + if not isinstance(check, dict): + results.append({"type": None, "ok": False, "detail": f"expected a check object, got {type(check).__name__}"}) + continue + kind = check.get("type") + checker = CHECKERS.get(kind) + if checker is None: + results.append({"type": kind, "ok": False, "detail": f"unknown check type {kind!r}"}) + continue + try: + results.append(checker(check)) + except (KeyError, TypeError) as error: + results.append({"type": kind, "ok": False, "detail": f"malformed check: {error}"}) + return results + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("path", help="JSON file of checks, or '-' for stdin") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + try: + text = sys.stdin.read() if args.path == "-" else open(args.path, encoding="utf-8").read() + checks = json.loads(text) + except OSError as error: + print(f"Could not read {args.path}: {error}", file=sys.stderr) + return 2 + except json.JSONDecodeError as error: + print(f"Invalid JSON in {args.path}: {error}", file=sys.stderr) + return 2 + + if not isinstance(checks, list): + print(f"Expected a JSON list of checks, got {type(checks).__name__}.", file=sys.stderr) + return 2 + + results = run(checks) + + if args.json: + print(json.dumps(results, indent=2)) + return 0 if all(r["ok"] for r in results) else 1 + + failed = [r for r in results if not r["ok"]] + print(f"{len(results)} check(s), {len(failed)} failed.\n") + for result in results: + mark = "OK" if result["ok"] else "FAIL" + print(f"[{mark}] {result['type']}: {result['detail']}") + return 0 if not failed else 1 + + +if __name__ == "__main__": + sys.exit(main())