diff --git a/community/astral/README.md b/community/astral/README.md new file mode 100644 index 00000000..5398b577 --- /dev/null +++ b/community/astral/README.md @@ -0,0 +1,89 @@ +# Astral — deterministic base capability +![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) +![Skill](https://img.shields.io/badge/Category-Skill-green?style=flat-square) + +Instant answers for the things with one right answer: time, date, math, money, unit conversions, grades, chemistry, physics, statistics, and number tools. Computed directly, no model. Anything else passes to your agent. + +This is the generalized version of the `date-and-time` base capability. The whole engine is inlined in `main.py` (pattern and table code, no `eval`), so it runs anywhere an agent runs, no DevKit required. + +## Category + +**Skill.** Trigger words route a phrase here; the engine answers and the agent handles the rest. + +## What it answers + +- Time and date, in the agent's timezone. +- Math: add, subtract, multiply, divide, percentages, powers, roots, factorials. +- Money: tips, tax, splitting a bill. +- Unit conversions: weight, length, volume, temperature, speed, area, time, energy, + pressure, force, data sizes, and astronomical distances. +- Grades: what you need on the final, weighted totals, percent to letter, score out of + total, GPA over credits. +- Chemistry: molar mass for a named compound or a formula, moles and grams, molarity, + pH, the ideal gas law, atomic mass and number for all 118 elements. +- Physics: escape velocity and surface gravity for the Sun, the Moon and every planet, + weight on another world, free fall, kinetic and potential energy, momentum, force, + work, power, Ohm's law, time dilation, Schwarzschild radius, photon energy, light + travel time. +- Statistics: mean, median, mode, range, variance, sample and population standard + deviation, z scores, combinations and permutations. +- Number tools: binary, hex and octal, logs, trig, GCD and LCM, primes and prime + factors, modulo, the quadratic formula, percent change, fractions, significant + figures, scientific notation. + +Examples: + +``` +what time is it -> It's 3:55 pm. +what's twenty percent of eighty -> 20 percent of 80 is 16. +convert ten pounds to kilograms -> 10 pounds is 4.54 kilograms. +eighteen percent tip on forty five dollars + -> A tip of 18 percent on 45 dollars is 8.1 dollars, for a total of 53.1. +I have an 87 and the final is worth 20 percent, what do I need to get a 90 + -> You'd need 102 percent on the final for 90 percent, + which isn't possible. A perfect final leaves you at 89.6 percent. +molar mass of water -> The molar mass of water (H2O) is 18.015 grams per mole. +escape velocity of mars -> Escape velocity at Mars is 5.02 kilometers per second, + 11234.25 miles per hour. +standard deviation of 4 6 8 10 -> The sample standard deviation of 4, 6, 8, 10 is 2.58, + around a mean of 7. +is 91 prime -> No, 91 isn't prime. It's 7 times 13. +tell me a joke -> (nothing; the agent takes it) +``` + +Two things it says out loud rather than assuming. Anything resting on a grading scale +names the scale, because a scale is a convention and not a fact. A standard deviation +says whether it is the sample or the population one, because those are different numbers +and a course grades you on which you used. + +## Suggested trigger words + +Set these in the dashboard: + +`what time`, `what's the time`, `what's the date`, `what day is it`, `calculate`, `what's`, `how much is`, `percent of`, `square root of`, `convert`, `how many`, `tip on`, `tax on`, `split`, `what do i need`, `what letter grade`, `out of`, `weighted`, `gpa`, `molar mass`, `how many moles`, `atomic mass`, `atomic number`, `molarity`, `ph of`, `escape velocity`, `surface gravity`, `kinetic energy`, `momentum`, `how long does light`, `average of`, `mean of`, `median of`, `standard deviation`, `z score`, `choose`, `in binary`, `in hexadecimal`, `log of`, `sine of`, `prime factors`, `quadratic`, `percent change`, `simplify`, `significant figures` + +## How it works + +`main.py` is a `MatchingCapability`. On a trigger word it takes the transcript, normalizes the punctuation, routes it through the inlined engine, and speaks the answer. The engine is one router with a fixed order: time and date first, then grades, chemistry, physics, statistics, number tools, and plain arithmetic last. Specific before general, because the general one will match a fragment of a specific question. If nothing matches, it speaks nothing and calls `resume_normal_flow()` so the agent takes the turn. Never blocks, never blanket-denies. + +## Accuracy + +Every answer is a formula over a table, so it is either exactly right or it does not +answer. Unit factors are the exact defined values. Molar masses are computed by parsing +the formula against the element table rather than stored per compound, because a +hand-entered constant is a typo waiting to be spoken with confidence. Surface gravity +and escape velocity are computed from mass and equatorial radius and agree with +published figures for eight bodies to better than half a percent. + +120 answers are asserted byte-exact in the source repo, alongside a suite that fuzzes +four thousand utterances and rechecks every result a second independent way — statistics +against Python's own `statistics` module, factorizations multiplied back out, physics +against published constants. + +## Requirements + +Python standard library only. No API keys, no external services. + +## Note + +This version computes in the cloud, without the LLM. For the fully on-device, no-network path (wake and speech-to-text on the DevKit too), see the local DevKit build. diff --git a/community/astral/__init__.py b/community/astral/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/astral/main.py b/community/astral/main.py new file mode 100644 index 00000000..9e529ae0 --- /dev/null +++ b/community/astral/main.py @@ -0,0 +1,1573 @@ +"""Astral - deterministic base capability for OpenHome. + +Answers the exact-answer class without the LLM: time, date, math, money, unit +conversions, grades, chemistry, physics, statistics, and number tools. The whole engine +is inlined below (pattern-and-table code, no model, no network), so this runs anywhere +an agent runs - no DevKit required. Modeled on the official date-and-time base +capability, generalized. + +The class is bigger than it first looks. "What do I need on the final to get a 90", +"molar mass of water", "escape velocity of Mars", "standard deviation of 4 6 8 10" all +have exactly one right answer and a formula that produces it. A model answers them +fluently and sometimes wrongly. This computes them, in microseconds, offline. + +If nothing matches, it speaks nothing and hands the turn back so the agent takes it. +""" +from __future__ import annotations +from datetime import datetime +from zoneinfo import ZoneInfo +import re + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + + +# ===== ASTRAL ENGINE — GENERATED BY hub/build_ability.py, DO NOT EDIT HERE ===== +# Source: hub/{mechanical,calc,study,chem,sci,stats,mathx,engine}.py +# Edit those and re-run `python3 hub/build_ability.py`. Hand edits here are lost. +import math +from fractions import Fraction + +# ── mechanical.py ────────────────────────────────────────────────────────── +# Mechanical command layer — deterministic, no-LLM, no-cloud, sub-millisecond. +# +# The efficiency wedge. Trivial commands (time / date / day / part-of-day) are +# DETERMINISTIC — the answer is computed, not reasoned. OpenHome currently routes +# them the full path: cloud STT → LLM agent → cloud TTS (multiple seconds, a model +# invocation, network). This catches them on-device before any of that spins up. +# +# Design goals (this is a demonstration piece — it must be flawless): +# • zero external calls, zero allocation beyond the string it returns +# • pure function of (utterance, now) — trivially testable, no globals +# • returns None when it's NOT a mechanical command → clean fall-through to the +# normal path, so it never swallows anything it shouldn't +# • small + self-contained enough to upstream into OpenHome OS as-is +# +# Timezone: uses the device's local clock. (Set the device TZ correctly — the DevKit +# image shipped Asia/Karachi; a real deploy should match the user.) + + +def _hm(now: datetime) -> str: + return now.strftime("%I:%M %p").lstrip("0").lower() + + +def _time(now): + return f"It's {_hm(now)}." + + +def _date(now): + return f"Today is {now.strftime('%A, %B %d, %Y').replace(' 0', ' ')}." + + +def _day(now): + return f"It's {now.strftime('%A')}." + + +def _month(now): + return f"It's {now.strftime('%B')}." + + +def _year(now): + return f"It's {now.strftime('%Y')}." + + +def _partofday(now): + h = now.hour + part = ("early morning" if h < 6 else "morning" if h < 12 else + "afternoon" if h < 17 else "evening" if h < 21 else "night") + return f"It's {part}, {_hm(now)}." + + +def _ampm(now): + return f"It's {now.strftime('%p').lower()}, {_hm(now)}." + + +# These have to be questions about NOW, not sentences that happen to contain a time +# word. The first version matched bare "the time", "the date", "what year" and "what +# month", which meant this module — the one that runs FIRST, so nothing else gets a +# turn after it — answered: +# +# "what year did the war end" -> "It's 2026." +# "what month is best to visit japan" -> "It's August." +# "what's the time zone in tokyo" -> "It's 3:55 pm." +# "what's the date of the super bowl" -> "Today is Friday, August 7, 2026." +# "what day of the week was I born" -> "Today is Friday, August 7, 2026." +# +# Eight for eight on a set of ordinary phrasings, each one confidently wrong and each +# one taking the turn from an agent that could have answered properly. Every pattern +# below now names the whole question instead of a word inside it. +HANDLERS = [ + (re.compile(r"\bwhat('?s| is) the date\b|\btoday'?s date\b|\bwhat('?s| is) today\b|" + r"\bwhat date is it\b|\btell me the date\b|\bthe date today\b"), _date), + (re.compile(r"\bwhat day is (it|today)\b|\bwhat('?s| is) the day\b|" + r"\bwhat day of the week is (it|today)\b"), _day), + (re.compile(r"\bwhat month is (it|this)\b|\bcurrent month\b|" + r"\bwhat('?s| is) the month\b"), _month), + (re.compile(r"\bwhat year is (it|this)\b|\bcurrent year\b|" + r"\bwhat('?s| is) the year\b"), _year), + (re.compile(r"\bmorning or (afternoon|evening|night)\b|\bpart of the day\b"), _partofday), + (re.compile(r"\bis it (am|pm|a\.m\.|p\.m\.)\b|\bam or pm\b"), _ampm), + (re.compile(r"\bwhat time is it\b|\bwhat('?s| is) the time\b|\bcurrent time\b|" + r"\btime is it now\b|\bgot the time\b|\b(do you )?have the time\b|" + r"\btell me the time\b"), _time), +] + +# Even an anchored pattern can sit inside a sentence plainly about something else. +# "Remind me at the time of the meeting" contains "the time"; so does "set an alarm for +# the time I usually wake up". A tense marker or a second subject means the question is +# not about this instant, and declining costs nothing — the agent takes the turn. +_NOT_NOW = re.compile( + r"\b(did|was|were|had|will|would|going to|used to|next|last|upcoming|previous|" + r"yesterday|tomorrow|born|ago|remind|reminder|alarm|schedule|calendar|" + r"time ?zone|best (time|month|day)|of the)\b") + + +def mech_handle(utterance: str, now: datetime | None = None) -> str | None: + """Return a spoken answer for a mechanical command, or None to fall through.""" + now = now or datetime.now() + t = " " + utterance.lower().strip() + " " + if _NOT_NOW.search(t): + return None + for pat, fn in HANDLERS: + if pat.search(t): + return fn(now) + return None + +# ── demo: the efficiency contrast ───────────────────────────────────────────── + + +# ── calc.py ──────────────────────────────────────────────────────────────── +# Astral calc — deterministic math, money, and unit conversion. No LLM, no cloud, µs. +# +# Handles the everyday-convenience commands that should never round-trip to a model: +# • arithmetic "what's fifteen plus twenty seven", "12 times 8", "half of 90" +# • percentages "20 percent of 80", "what's 15 percent of 200" +# • money "18 percent tip on 45 dollars", "split 120 between 4", +# "8.5 percent tax on 60" +# • conversions "convert 5 pounds to kilograms", "how many cups in 2 liters", +# "6 feet in meters", "70 fahrenheit to celsius" +# +# handle(text) -> spoken string, or None to fall through to the normal path. +# All parsing is pattern + table based (no eval) so it is safe on voice input. +# ── number words → value (STT emits words: "twenty five", "one hundred") ────── +_ONES = {"zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, + "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13, + "fourteen": 14, "fifteen": 15, "sixteen": 16, "seventeen": 17, "eighteen": 18, + "nineteen": 19, "a": 1, "an": 1} +_TENS = {"twenty": 20, "thirty": 30, "forty": 40, "fifty": 50, "sixty": 60, "seventy": 70, + "eighty": 80, "ninety": 90} +_SCALE = {"thousand": 1000, "million": 1000000, "billion": 1000000000} + + +def _int_words(toks: list[str]) -> float: + # "a"/"an" is only the number one when nothing else in the run is a number. + # "a mile" is 1 mile, but "a 15 percent tip" is 15, not 16, and "an 87" is 87, + # not 88. Counting the article unconditionally made every article-plus-digit + # phrase answer one too high — a spoken-wrong answer, which is the one thing + # this engine exists to make impossible. + real = any(t.replace(".", "", 1).isdigit() or t in _TENS or t == "hundred" + or t in _SCALE or (t in _ONES and t not in ("a", "an")) + for t in toks) + total, cur = 0, 0 + for t in toks: + if t.replace(".", "", 1).isdigit(): + cur += float(t) + elif t in ("a", "an"): + cur += 0 if real else 1 + elif t in _ONES: + cur += _ONES[t] + elif t in _TENS: + cur += _TENS[t] + elif t == "hundred": + cur = (cur or 1) * 100 + elif t in _SCALE: + total += (cur or 1) * _SCALE[t] + cur = 0 + # ignore "and" + return total + cur + + +def parse_number(s: str): + s = s.strip().lower().replace("-", " ") + try: + return float(s) + except ValueError: + pass + if "point" in s: + whole, frac = s.split("point", 1) + w = _int_words(whole.split()) if whole.strip() else 0 + digits = "".join(str(int(_ONES[t])) for t in frac.split() if t in _ONES) + return float(f"{w}.{digits}") if digits else w + toks = [t for t in s.split() if t in _ONES or t in _TENS or t == "hundred" + or t in _SCALE or t.replace(".", "", 1).isdigit()] + return _int_words(toks) if toks else None + + +_NUMRUN = re.compile(r"((?:\b(?:zero|one|two|three|four|five|six|seven|eight|nine|ten|" + r"eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|" + r"twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|thousand|million|" + r"point|and|a|an)\b|\d+(?:\.\d+)?)(?:\s+|$))+") + + +def numbers(text: str) -> list[float]: + out = [] + for m in _NUMRUN.finditer(text.lower()): + run = m.group(0).strip() + # A run that is nothing but an article is not a number. "a photon" was + # yielding 1 and shifting every positional formula by one argument; the + # legitimate "how many feet in a mile" case is handled by _convert, which + # assumes a quantity of one when no number is spoken at all. + if all(w in ("a", "an", "and") for w in run.split()): + continue + v = parse_number(run) + if v is not None: + out.append(v) + return out + + +def _fmt(x: float) -> str: + # round(), not int(). int() truncates, so a float that lands a hair BELOW an + # integer — 101.99999999999996 out of any ordinary division — printed as 101 + # while the guard above had already decided it was an integer. Off by one, out + # loud, with full confidence. + # "close enough to an integer" is also true of every number smaller than the + # tolerance, so 1e-10 was being called an integer and printed as 0 — the same + # say-zero-about-a-non-zero-number bug as below, one branch earlier. A genuinely + # tiny value is not an integer; it falls through to the spoken form. + if abs(x - round(x)) < 1e-9 and not (x and abs(x) < 1e-9): + return str(int(round(x))) + s = f"{x:.2f}".rstrip("0").rstrip(".") + # Two decimals turn every non-zero value under 0.005 into "0", so "how many atoms + # in 0.000001 moles" answered "0 moles is ...". Saying zero about something that + # is not zero is the failure this engine exists to prevent, even in an echo. + if s in ("0", "-0"): + # Said, not printed: "1e-06" is read aloud as gibberish. Same phrasing as + # _fmt_spoken uses at the other end of the scale. + g = f"{x:.4g}" + return g.replace("e-0", " times ten to the minus ").replace( + "e-", " times ten to the minus ") if "e" in g else g + return s + + +def _fmt_spoken(x: float) -> str: + """Same number, said out loud. 9460730472580.8 is not an answer anyone can hear, + so anything past a million becomes '9.46 trillion' and anything under a millionth + keeps its significant digits instead of collapsing to '0'.""" + a = abs(x) + if a >= 1e15: + return f"{x/1e15:.2f}".rstrip("0").rstrip(".") + " quadrillion" + if a >= 1e12: + return f"{x/1e12:.2f}".rstrip("0").rstrip(".") + " trillion" + if a >= 1e9: + return f"{x/1e9:.2f}".rstrip("0").rstrip(".") + " billion" + if a >= 1e6: + return f"{x/1e6:.2f}".rstrip("0").rstrip(".") + " million" + if a and a < 0.001: + return f"{x:.4g}".replace("e-0", " times ten to the minus ").replace("e-", " times ten to the minus ") + return _fmt(x) + + +_ROMAN = [(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), (90, "XC"), + (50, "L"), (40, "XL"), (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")] + + +def _to_roman(n: int) -> str: + out = "" + for v, s in _ROMAN: + while n >= v: + out += s + n -= v + return out + + +# ── unit conversion tables (to a base per dimension) ────────────────────────── +# Factors are the exact defined values, not rounded ones. A truncated constant is a +# wrong answer with extra steps: mile = 1609.34 answered "a mile is 5279.99 feet". +_WEIGHT = {"gram": 1, "grams": 1, "g": 1, "kilogram": 1000, "kilograms": 1000, "kg": 1000, + "kilo": 1000, "kilos": 1000, "milligram": 0.001, "milligrams": 0.001, "mg": 0.001, + "pound": 453.59237, "pounds": 453.59237, "lb": 453.59237, + "lbs": 453.59237, "ounce": 28.349523125, "ounces": 28.349523125, "oz": 28.349523125, + # "ton" from an American voice is the short ton; the metric one has its own + # names. Collapsing them into one number was a factor-of-1.1 error waiting. + "ton": 907184.74, "tons": 907184.74, "short ton": 907184.74, "short tons": 907184.74, + "tonne": 1e6, "tonnes": 1e6, "metric ton": 1e6, "metric tons": 1e6, + "stone": 6350.29318, "amu": 1.66053906660e-24} +_LENGTH = {"meter": 1, "meters": 1, "metre": 1, "m": 1, "centimeter": 0.01, "centimeters": 0.01, + "cm": 0.01, "millimeter": 0.001, "mm": 0.001, "micrometer": 1e-6, "micron": 1e-6, + "nanometer": 1e-9, "nanometers": 1e-9, "nm": 1e-9, + "kilometer": 1000, "kilometers": 1000, + "km": 1000, "inch": 0.0254, "inches": 0.0254, "foot": 0.3048, "feet": 0.3048, + "yard": 0.9144, "yards": 0.9144, "mile": 1609.344, "miles": 1609.344, + "nautical mile": 1852, "nautical miles": 1852, + # astronomical distances — exact IAU definitions + "light year": 9.4607304725808e15, "light years": 9.4607304725808e15, + "lightyear": 9.4607304725808e15, "lightyears": 9.4607304725808e15, + "parsec": 3.0856775814913673e16, "parsecs": 3.0856775814913673e16, + "astronomical unit": 1.495978707e11, "astronomical units": 1.495978707e11} +_VOLUME = {"liter": 1, "liters": 1, "litre": 1, "l": 1, "milliliter": 0.001, "milliliters": 0.001, + "ml": 0.001, "cup": 0.2365882365, "cups": 0.2365882365, + "gallon": 3.785411784, "gallons": 3.785411784, + "quart": 0.946352946, "quarts": 0.946352946, "pint": 0.473176473, + "pints": 0.473176473, "tablespoon": 0.01478676478125, + "tablespoons": 0.01478676478125, "teaspoon": 0.00492892159375, + "teaspoons": 0.00492892159375, "cubic meter": 1000, "cubic meters": 1000} +# base = meters per second +_SPEED = {"mph": 0.44704, "kph": 1 / 3.6, "kmh": 1 / 3.6, "knot": 1852 / 3600, "knots": 1852 / 3600, + "mps": 1, "meters per second": 1, "feet per second": 0.3048, + "miles per hour": 0.44704, "kilometers per hour": 1 / 3.6} +# base = square meters +_AREA = {"square meter": 1, "square meters": 1, "sqm": 1, "square foot": 0.09290304, + "square feet": 0.09290304, "sqft": 0.09290304, "acre": 4046.8564224, + "acres": 4046.8564224, "square inch": 0.00064516, "square inches": 0.00064516, + "hectare": 10000, "hectares": 10000, "square kilometer": 1e6, "square kilometers": 1e6, + "square mile": 2589988.110336, "square miles": 2589988.110336} +# base = seconds (time DURATION units, not the clock — those are mechanical.py) +_TIME = {"second": 1, "seconds": 1, "minute": 60, "minutes": 60, "hour": 3600, "hours": 3600, + "day": 86400, "days": 86400, "week": 604800, "weeks": 604800, + "year": 31557600, "years": 31557600, "millisecond": 0.001, "milliseconds": 0.001} +# base = joules +_ENERGY = {"joule": 1, "joules": 1, "kilojoule": 1000, "kilojoules": 1000, "kj": 1000, + "calorie": 4.184, "calories": 4.184, "kilocalorie": 4184, "kilocalories": 4184, + "food calorie": 4184, "food calories": 4184, + "watt hour": 3600, "watt hours": 3600, "kilowatt hour": 3.6e6, + "kilowatt hours": 3.6e6, "electron volt": 1.602176634e-19, + "electron volts": 1.602176634e-19, "btu": 1055.05585262} +# base = pascals +_PRESSURE = {"pascal": 1, "pascals": 1, "kilopascal": 1000, "kilopascals": 1000, "kpa": 1000, + "bar": 100000, "bars": 100000, "atmosphere": 101325, "atmospheres": 101325, + "atm": 101325, "psi": 6894.757293168, "torr": 133.32236842105263, + "millimeters of mercury": 133.32236842105263} +# base = newtons +_FORCE = {"newton": 1, "newtons": 1, "kilonewton": 1000, "kilonewtons": 1000, + "pound force": 4.4482216152605, "pounds force": 4.4482216152605, "dyne": 1e-5} +# base = bytes (binary multiples — the convention every CS course teaches) +_DATA = {"byte": 1, "bytes": 1, "bit": 0.125, "bits": 0.125, "kilobyte": 1024, "kilobytes": 1024, + "kb": 1024, "megabyte": 1048576, "megabytes": 1048576, "mb": 1048576, + "gigabyte": 1073741824, "gigabytes": 1073741824, "gb": 1073741824, + "terabyte": 1099511627776, "terabytes": 1099511627776, "tb": 1099511627776, + "megabit": 131072, "megabits": 131072, "gigabit": 134217728, "gigabits": 134217728} +# Order matters: a compound unit contains a simple one, so the compound dimension has +# to win the match. "square meter" contains "meter" (area before length), "meters per +# second" contains "meters" (speed before length), "kilowatt hour" contains "hour" +# (energy before time), "millimeters of mercury" contains "millimeters" (pressure +# before length). Reordering this dict silently changes answers — add new dimensions +# above the simple ones they can shadow, and add a golden line for the collision. +_DIMS = {"area": _AREA, "energy": _ENERGY, "speed": _SPEED, "pressure": _PRESSURE, + "force": _FORCE, "data": _DATA, "weight": _WEIGHT, "length": _LENGTH, + "volume": _VOLUME, "time": _TIME} + +# One compiled alternation per dimension instead of one regex per unit. The old +# version ran ~250 separate re.search calls on every sentence — more than the re +# module's pattern cache holds — which measured 1.7 ms per conversion on the Pi. +# Longest-first also fixes a real shadowing bug: "nautical mile" used to match both +# "nautical mile" AND "mile" at overlapping positions, and _convert picks by distance +# to the number, so the wrong one could win and answer a statute-mile conversion. +_DIM_RE = {dim: re.compile(r"\b(" + "|".join(re.escape(u) for u in + sorted(table, key=len, reverse=True)) + r")\b") + for dim, table in _DIMS.items()} + + +def _find_units(text: str): + """(dim, unit, position) for every unit word, so we can order by the sentence.""" + found = [] + for dim, pattern in _DIM_RE.items(): + seen = set() + for m in pattern.finditer(text): + if m.group(1) not in seen: # first occurrence of each unit, as before + seen.add(m.group(1)) + found.append((dim, m.group(1), m.start())) + return found + + +def _convert(text: str, nums): + # "how many feet in a mile" names no quantity — the question is about one of them. + # Only an explicit asking shape gets that default, so merely mentioning two units + # in passing doesn't make Astral volunteer a conversion nobody requested. + if nums: + v = nums[0] + elif re.search(r"\bhow many\b|\bconvert\b|\bin (?:a|an|one)\b", text): + v = 1 + else: + return None + nm = _NUMRUN.search(text) + npos = nm.start() if nm else 0 # source = the unit nearest the number + + # temperature (non-linear) — direction from which unit sits by the number + f = re.search(r"\bfahrenheit\b", text) + c = re.search(r"\b(?:celsius|centigrade)\b", text) + if f and c: + return (f"{_fmt(v)} degrees Fahrenheit is {_fmt((v-32)*5/9)} degrees Celsius." + if abs(f.start() - npos) <= abs(c.start() - npos) else + f"{_fmt(v)} degrees Celsius is {_fmt(v*9/5+32)} degrees Fahrenheit.") + + units = _find_units(text) + for dim in _DIMS: + du = [u for u in units if u[0] == dim] + if len(du) >= 2: + du.sort(key=lambda x: abs(x[2] - npos)) # nearest number = source + src, tgt = du[0], du[1] + base = v * _DIMS[dim][src[1]] + return f"{_fmt(v)} {src[1]} is {_fmt_spoken(base / _DIMS[dim][tgt[1]])} {tgt[1]}." + return None + +# ── main ────────────────────────────────────────────────────────────────────── + + +def calc_handle(text: str) -> str | None: + t = " " + text.lower().strip() + " " + nums = numbers(text) + + # conversions first (they contain unit words) + conv = _convert(t, nums) + if conv: + return conv + + # money: tip + if "tip" in t and nums: + amount = max(nums) + pct = next((n for n in nums if n != amount and n <= 100), 18) + tip = amount * pct / 100 + return (f"A tip of {_fmt(pct)} percent on {_fmt(amount)} dollars is " + f"{_fmt(tip)} dollars, for a total of {_fmt(amount + tip)}.") + # money: tax + if "tax" in t and len(nums) >= 2: + pct, amount = min(nums), max(nums) + tax = amount * pct / 100 + return (f"{_fmt(pct)} percent tax on {_fmt(amount)} is {_fmt(tax)} dollars, " + f"total {_fmt(amount + tax)}.") + # money: split + if ("split" in t or "divide" in t) and ("between" in t or "among" in t or "people" in t) and len(nums) >= 2: + amount, people = max(nums), min(nums) + if people: + return f"{_fmt(amount)} split between {_fmt(people)} is {_fmt(amount/people)} dollars each." + + # percentage: "P percent of X" + m = re.search(r"([0-9.]+|\w[\w ]*?)\s+percent of\s+([0-9.]+|\w[\w ]*)", t) + if m: + p, x = parse_number(m.group(1)), parse_number(m.group(2)) + if p is not None and x is not None: + return f"{_fmt(p)} percent of {_fmt(x)} is {_fmt(x*p/100)}." + + # half / quarter / double of X + if "half of" in t and nums: + return f"Half of {_fmt(nums[0])} is {_fmt(nums[0]/2)}." + if ("double" in t or "twice" in t) and nums: + return f"Double {_fmt(nums[0])} is {_fmt(nums[0]*2)}." + + # square root / squared + if "square root of" in t and nums: + return f"The square root of {_fmt(nums[0])} is {_fmt(nums[0] ** 0.5)}." + if ("squared" in t) and nums: + return f"{_fmt(nums[0])} squared is {_fmt(nums[0] ** 2)}." + if "cubed" in t and nums: + return f"{_fmt(nums[0])} cubed is {_fmt(nums[0] ** 3)}." + if "cube root of" in t and nums: + return f"The cube root of {_fmt(nums[0])} is {_fmt(round(nums[0] ** (1 / 3), 4))}." + if "to the power" in t and len(nums) >= 2: + return f"{_fmt(nums[0])} to the power of {_fmt(nums[1])} is {_fmt(nums[0] ** nums[1])}." + if "factorial" in t and nums: + n = int(nums[0]) + if 0 <= n <= 20: + f = 1 + for i in range(2, n + 1): + f *= i + return f"{n} factorial is {_fmt(f)}." + if "roman numeral" in t and nums: + n = int(nums[0]) + if 0 < n < 4000: + return f"{_fmt(n)} in roman numerals is {_to_roman(n)}." + + # arithmetic: A B + if len(nums) >= 2: + a, b = nums[0], nums[1] + if re.search(r"\bplus\b|\badd\b|\band\b", t) and not any(w in t for w in ("percent", "tip", "tax", "split")): + if "plus" in t or "add" in t: + return f"{_fmt(a)} plus {_fmt(b)} is {_fmt(a+b)}." + if re.search(r"\bminus\b|\bsubtract\b|\bless\b|\btake away\b", t): + return f"{_fmt(a)} minus {_fmt(b)} is {_fmt(a-b)}." + if re.search(r"\btimes\b|\bmultiplied\b|\bmultiply\b", t) or re.search(r"\bx\b", t): + return f"{_fmt(a)} times {_fmt(b)} is {_fmt(a*b)}." + if re.search(r"\bdivided by\b|\bdivide\b|\bover\b", t): + if b: + return f"{_fmt(a)} divided by {_fmt(b)} is {_fmt(a/b)}." + return None + + +# ── study.py ─────────────────────────────────────────────────────────────── +# Astral study — deterministic grade arithmetic. No LLM, no cloud, µs. +# +# The questions a student actually asks out loud, all of them plain arithmetic that a +# model has no business guessing at: +# +# • what's left "I have an 87 and the final is worth 20 percent, +# what do I need to get a 90" +# • score to percent "what's my grade if I got 42 out of 50" +# • letter bands "what letter grade is an 87" +# • weighted total "homework is 90 worth 20 percent and exams are 84 worth 80 percent" +# • gpa "gpa for a 4.0 in 3 credits and a 3.0 in 4 credits" +# +# Every answer that depends on a grading scale says which scale it used, because the +# scale is a convention and not a fact. Saying "an 87 is a B plus" without naming the +# bands would be the confidently-wrong behaviour this whole layer exists to avoid. +# +# handle(text) -> spoken string, or None to fall through. +# Standard 90/80/70 scale with plus-minus bands. Floor percent -> letter. +_GR_BANDS = [(97, "A plus"), (93, "A"), (90, "A minus"), (87, "B plus"), (83, "B"), + (80, "B minus"), (77, "C plus"), (73, "C"), (70, "C minus"), + (67, "D plus"), (63, "D"), (60, "D minus"), (0, "F")] + +# Whole-letter targets, used when a target is spoken as a letter rather than a number. +_GR_TARGET = {"a": 90, "b": 80, "c": 70, "d": 60} + + +def _gr_letter(pct: float) -> str: + for floor, name in _GR_BANDS: # the table ends at 0, so this always returns + if pct >= floor: + return name + + +def _gr_article(letter: str) -> str: + return "an" if letter[0] in "aef" else "a" + + +def _gr_target_percent(t: str): + """Target as a number ('90') or as a letter ('a B'). None when absent.""" + m = re.search(r"\b(?:to get|for|get|earn|end with|finish with|need)\s+" + r"(?:an?\s+)?([0-9]{1,3}(?:\.[0-9]+)?)\s*(?:percent|%)?", t) + if m: + v = float(m.group(1)) + if 0 < v <= 150: + return v, f"{_fmt(v)} percent" + m = re.search(r"\b(?:to get|for|get|earn|end with|finish with|need)\s+an?\s+" + r"\b([abcd])\b", t) + if m: + letter = m.group(1) + return _GR_TARGET[letter], f"{letter.upper()} at {_GR_TARGET[letter]} percent" + return None + + +def _gr_pairs(t: str): + """(value, weight) pairs from 'X worth Y percent' phrasings, in sentence order.""" + pairs = [] + for m in re.finditer(r"([0-9]{1,3}(?:\.[0-9]+)?)\s*(?:percent|%)?[^0-9]{0,24}?" + r"(?:worth|weighted|weighs|counts for|is)\s+" + r"([0-9]{1,3}(?:\.[0-9]+)?)\s*(?:percent|%)", t): + pairs.append((float(m.group(1)), float(m.group(2)))) + return pairs + + +def study_handle(text: str) -> str | None: + t = " " + text.lower().strip() + " " + + # ── what do I need on the final ─────────────────────────────────────────── + if re.search(r"\bfinal\b|\blast (?:exam|test)\b", t) and re.search(r"\bneed\b|\bhave to (?:get|score)\b", t): + wm = re.search(r"(?:worth|counts for|weighted|is)\s+([0-9]{1,3}(?:\.[0-9]+)?)\s*(?:percent|%)", t) + cm = re.search(r"(?:i have|i've got|i got|currently|current(?:ly)? (?:at|have)|grade is|sitting at)" + r"\s+an?\s*([0-9]{1,3}(?:\.[0-9]+)?)", t) + tgt = _gr_target_percent(t) + if wm and cm and tgt: + weight = float(wm.group(1)) / 100 + current = float(cm.group(1)) + target, target_label = tgt + if 0 < weight < 1: + needed = (target - current * (1 - weight)) / weight + best = current * (1 - weight) + 100 * weight + worst = current * (1 - weight) + if needed > 100: + return (f"You'd need {_fmt(needed)} percent on the final for {target_label}, " + f"which isn't possible. A perfect final leaves you at {_fmt(best)} percent.") + if needed <= 0: + return (f"You already have it. Even a zero on the final leaves you at " + f"{_fmt(worst)} percent, above {target_label}.") + return (f"You need {_fmt(needed)} percent on the final to finish with " + f"{target_label}, starting from {_fmt(current)} percent with the " + f"final worth {_fmt(weight * 100)} percent.") + + # ── weighted course total ───────────────────────────────────────────────── + # Excluding every sentence containing "final" was too blunt: a student listing a + # course says "quizzes 70 worth 10 percent, labs 95 worth 30, final 88 worth 60", + # and that is exactly this question. Only the needed-on-the-final question is + # withheld here, and only because the branch above owns it — if that branch has an + # incomplete sentence it stays silent rather than letting this one answer something + # adjacent but different. + if re.search(r"\bworth\b|\bweighted\b|\bcounts for\b", t) and not ( + re.search(r"\bfinal\b", t) and re.search(r"\bneed\b|\bhave to (?:get|score)\b", t)): + pairs = _gr_pairs(t) + if len(pairs) >= 2: + total_w = sum(w for _, w in pairs) + if total_w > 0: + score = sum(v * w for v, w in pairs) / total_w + letter = _gr_letter(score) + tail = ("" if abs(total_w - 100) < 1e-9 else + f" That's out of {_fmt(total_w)} percent of the course so far.") + return (f"Your weighted grade is {_fmt(score)} percent, " + f"{_gr_article(letter.lower())} {letter} on a standard 90/80/70 scale.{tail}") + + # ── gpa from (points, credits) pairs ────────────────────────────────────── + if re.search(r"\bg\.?p\.?a\b|\bgrade point average\b", t): + nums = numbers(text) + if len(nums) >= 4 and len(nums) % 2 == 0: + pts = nums[0::2] + cred = nums[1::2] + if all(0 <= p <= 4.5 for p in pts) and all(c > 0 for c in cred): + total_c = sum(cred) + gpa = sum(p * c for p, c in zip(pts, cred)) / total_c + return (f"That's a {gpa:.2f} GPA over {_fmt(total_c)} credits.") + if len(nums) == 2 and all(0 <= n <= 4.5 for n in nums): + return None # ambiguous — one pair could be anything; stay quiet + + # ── letter for a percent ────────────────────────────────────────────────── + if re.search(r"\bletter grade\b|\bwhat grade is\b|\bis that an? [a-f]\b", t): + nums = numbers(text) + if nums and 0 <= nums[0] <= 100: + letter = _gr_letter(nums[0]) + return (f"{_fmt(nums[0])} percent is {_gr_article(letter.lower())} {letter} " + f"on a standard 90/80/70 scale.") + + # ── score out of total ──────────────────────────────────────────────────── + m = re.search(r"([0-9]+(?:\.[0-9]+)?)\s+(?:out of|over|of)\s+([0-9]+(?:\.[0-9]+)?)", t) + if m and re.search(r"\bgrade\b|\bscore\b|\bgot\b|\bpercent\b|\bmissed\b", t): + got, total = float(m.group(1)), float(m.group(2)) + if total > 0 and got <= total * 1.5: + pct = got / total * 100 + letter = _gr_letter(pct) + return (f"{_fmt(got)} out of {_fmt(total)} is {_fmt(pct)} percent, " + f"{_gr_article(letter.lower())} {letter} on a standard 90/80/70 scale.") + + return None + + +# ── chem.py ──────────────────────────────────────────────────────────────── +# Astral chem — deterministic chemistry. No LLM, no cloud, µs. +# +# • elements "atomic mass of carbon", "atomic number of iron", "symbol for potassium" +# • compounds "molar mass of water", "molecular weight of glucose", "molar mass of CaCO3" +# • moles "how many moles in 36 grams of water", +# "how many grams in 2 moles of glucose", "how many atoms in 2 moles" +# • solutions "molarity of 2 moles in 4 liters", "ph of a 0.001 molar solution" +# • gases "volume of 2 moles at 300 kelvin and 1 atmosphere" +# +# Molar masses are COMPUTED from the element table by parsing the formula, never typed +# in per compound. A hand-entered molar mass is a typo waiting to be spoken with total +# confidence; a parsed one is wrong only if the periodic table is wrong. +# +# Elements with no stable isotope carry their longest-lived isotope's mass and say so. +# +# handle(text) -> spoken string, or None to fall through. +_CH_NA = 6.02214076e23 # Avogadro, exact +_CH_R = 0.082057366 # gas constant, L·atm/(mol·K) + +# symbol name mass (mass* = no stable isotope; the longest-lived one) +_CH_RAW = """H hydrogen 1.008|He helium 4.0026|Li lithium 6.94|Be beryllium 9.0122| +B boron 10.81|C carbon 12.011|N nitrogen 14.007|O oxygen 15.999|F fluorine 18.998| +Ne neon 20.180|Na sodium 22.990|Mg magnesium 24.305|Al aluminum 26.982| +Si silicon 28.085|P phosphorus 30.974|S sulfur 32.06|Cl chlorine 35.45|Ar argon 39.95| +K potassium 39.098|Ca calcium 40.078|Sc scandium 44.956|Ti titanium 47.867| +V vanadium 50.942|Cr chromium 51.996|Mn manganese 54.938|Fe iron 55.845| +Co cobalt 58.933|Ni nickel 58.693|Cu copper 63.546|Zn zinc 65.38|Ga gallium 69.723| +Ge germanium 72.630|As arsenic 74.922|Se selenium 78.971|Br bromine 79.904| +Kr krypton 83.798|Rb rubidium 85.468|Sr strontium 87.62|Y yttrium 88.906| +Zr zirconium 91.224|Nb niobium 92.906|Mo molybdenum 95.95|Tc technetium 98*| +Ru ruthenium 101.07|Rh rhodium 102.91|Pd palladium 106.42|Ag silver 107.87| +Cd cadmium 112.41|In indium 114.82|Sn tin 118.71|Sb antimony 121.76| +Te tellurium 127.60|I iodine 126.90|Xe xenon 131.29|Cs cesium 132.91| +Ba barium 137.33|La lanthanum 138.91|Ce cerium 140.12|Pr praseodymium 140.91| +Nd neodymium 144.24|Pm promethium 145*|Sm samarium 150.36|Eu europium 151.96| +Gd gadolinium 157.25|Tb terbium 158.93|Dy dysprosium 162.50|Ho holmium 164.93| +Er erbium 167.26|Tm thulium 168.93|Yb ytterbium 173.05|Lu lutetium 174.97| +Hf hafnium 178.49|Ta tantalum 180.95|W tungsten 183.84|Re rhenium 186.21| +Os osmium 190.23|Ir iridium 192.22|Pt platinum 195.08|Au gold 196.97| +Hg mercury 200.59|Tl thallium 204.38|Pb lead 207.2|Bi bismuth 208.98| +Po polonium 209*|At astatine 210*|Rn radon 222*|Fr francium 223*|Ra radium 226*| +Ac actinium 227*|Th thorium 232.04|Pa protactinium 231.04|U uranium 238.03| +Np neptunium 237*|Pu plutonium 244*|Am americium 243*|Cm curium 247*| +Bk berkelium 247*|Cf californium 251*|Es einsteinium 252*|Fm fermium 257*| +Md mendelevium 258*|No nobelium 259*|Lr lawrencium 266*|Rf rutherfordium 267*| +Db dubnium 268*|Sg seaborgium 269*|Bh bohrium 270*|Hs hassium 269*| +Mt meitnerium 278*|Ds darmstadtium 281*|Rg roentgenium 282*|Cn copernicium 285*| +Nh nihonium 286*|Fl flerovium 289*|Mc moscovium 290*|Lv livermorium 293*| +Ts tennessine 294*|Og oganesson 294*""" + +_CH_MASS, _CH_NAME, _CH_NUM, _CH_BY_NAME, _CH_UNSTABLE = {}, {}, {}, {}, set() +for _i, _row in enumerate(x.strip() for x in _CH_RAW.replace("\n", "").split("|")): + _sym, _nm, _ms = _row.split() + if _ms.endswith("*"): + _ms = _ms[:-1] + _CH_UNSTABLE.add(_sym) + _CH_MASS[_sym] = float(_ms) + _CH_NAME[_sym] = _nm + _CH_NUM[_sym] = _i + 1 + _CH_BY_NAME[_nm] = _sym +_CH_BY_NAME.update({"aluminium": "Al", "sulphur": "S", "caesium": "Cs"}) + +# Spoken names -> formula. The molar mass is then computed, not stored. +_CH_COMPOUND = { + "water": "H2O", "heavy water": "D2O", "table salt": "NaCl", "salt": "NaCl", + "sodium chloride": "NaCl", "glucose": "C6H12O6", "sucrose": "C12H22O11", + "table sugar": "C12H22O11", "sugar": "C12H22O11", "carbon dioxide": "CO2", + "carbon monoxide": "CO", "oxygen gas": "O2", "nitrogen gas": "N2", + "hydrogen gas": "H2", "ozone": "O3", "ammonia": "NH3", "methane": "CH4", + "ethane": "C2H6", "propane": "C3H8", "butane": "C4H10", "ethanol": "C2H6O", + "methanol": "CH4O", "acetic acid": "C2H4O2", "sulfuric acid": "H2SO4", + "hydrochloric acid": "HCl", "nitric acid": "HNO3", "phosphoric acid": "H3PO4", + "sodium hydroxide": "NaOH", "potassium hydroxide": "KOH", + "calcium carbonate": "CaCO3", "sodium bicarbonate": "NaHCO3", + "baking soda": "NaHCO3", "calcium hydroxide": "Ca(OH)2", "magnesium oxide": "MgO", + "aluminum oxide": "Al2O3", "iron oxide": "Fe2O3", "silicon dioxide": "SiO2", + "hydrogen peroxide": "H2O2", "urea": "CH4N2O", "benzene": "C6H6", + "caffeine": "C8H10N4O2", "aspirin": "C9H8O4", "acetone": "C3H6O", + "ammonium nitrate": "NH4NO3", "calcium chloride": "CaCl2", + "potassium chloride": "KCl", "sodium sulfate": "Na2SO4", + "copper sulfate": "CuSO4", "silver nitrate": "AgNO3", "nitrous oxide": "N2O", +} +_CH_MASS["D"] = 2.014 # deuterium, for heavy water +_CH_TOKEN = re.compile(r"([A-Z][a-z]?)(\d*)|(\()|(\))(\d*)") + + +def _ch_count(x: float) -> str: + """Avogadro-scale counts spoken as powers of ten. '1204428152 quadrillion' is + not a number anyone can hear.""" + if x >= 1e15: + exp = math.floor(math.log10(x)) + return f"{x/10**exp:.3g} times ten to the {exp}" + return _fmt_spoken(x) + + +def _ch_molar(formula: str): + """Molar mass of a formula, parentheses included. None if any symbol is unknown.""" + stack, total = [], 0.0 + if not re.fullmatch(r"(?:[A-Z][a-z]?\d*|\(|\)\d*)+", formula): + return None + for m in _CH_TOKEN.finditer(formula): + sym, count, open_p, close_p, close_n = m.groups() + if open_p: + stack.append(total) + total = 0.0 + elif close_p: + if not stack: + return None + total = total * (int(close_n) if close_n else 1) + stack.pop() + else: + if sym not in _CH_MASS: + return None + total += _CH_MASS[sym] * (int(count) if count else 1) + return None if stack else total + + +def _ch_find(text: str, t: str): + """(display name, formula, molar mass) for whatever compound was named.""" + for name in sorted(_CH_COMPOUND, key=len, reverse=True): + if re.search(r"\b" + re.escape(name) + r"\b", t): + f = _CH_COMPOUND[name] + return name, f, _ch_molar(f) + for nm, sym in _CH_BY_NAME.items(): + if re.search(r"\b" + nm + r"\b", t): + return nm, sym, _CH_MASS[sym] + for tok in re.findall(r"\b((?:[A-Z][a-z]?\d*|\([A-Za-z0-9]+\)\d*)+)\b", text): + if len(tok) > 1 or tok in _CH_MASS: + mass = _ch_molar(tok) + if mass: + return tok, tok, mass + return None, None, None + + +def chem_handle(text: str) -> str | None: + t = " " + text.lower().strip() + " " + nums = numbers(text) + + # ── element facts ───────────────────────────────────────────────────────── + if re.search(r"\batomic (?:mass|weight)\b", t): + for nm, sym in _CH_BY_NAME.items(): + if re.search(r"\b" + nm + r"\b", t): + tail = (" — it has no stable isotope, so that's its longest-lived one" + if sym in _CH_UNSTABLE else "") + return (f"The atomic mass of {nm} is {_CH_MASS[sym]:g} atomic mass " + f"units{tail}.") + if re.search(r"\batomic number\b", t): + for nm, sym in _CH_BY_NAME.items(): + if re.search(r"\b" + nm + r"\b", t): + return f"{nm.title()} is element number {_CH_NUM[sym]}, symbol {sym}." + if re.search(r"\bsymbol for\b|\bchemical symbol\b", t): + for nm, sym in _CH_BY_NAME.items(): + if re.search(r"\b" + nm + r"\b", t): + return f"The symbol for {nm} is {sym}, element number {_CH_NUM[sym]}." + + # ── molar mass ──────────────────────────────────────────────────────────── + if re.search(r"\bmolar mass\b|\bmolecular (?:mass|weight)\b|\bformula (?:mass|weight)\b", t): + name, formula, mass = _ch_find(text, t) + if mass: + said = f"{name} ({formula})" if name != formula else formula + return f"The molar mass of {said} is {mass:.3f} grams per mole." + return None + + # ── moles <-> grams ─────────────────────────────────────────────────────── + if re.search(r"\bmoles?\b", t) and re.search(r"\bgrams?\b", t): + name, formula, mass = _ch_find(text, t) + if mass and nums: + # The question word decides the direction. "how many grams in 2 moles" + # and "how many moles in 36 grams" mention both units in the same order, + # so word order cannot be the signal — asking for grams and being told + # moles is a fluent, confident, wrong answer. + if re.search(r"how many grams?\b|\bgrams? (?:are |is )?in\b.*\bmoles?\b", t) \ + and not re.search(r"how many moles?\b", t): + want = "grams" + elif re.search(r"how many moles?\b", t): + want = "moles" + else: + want = "moles" if re.search(r"\bgrams?\b", t).start() < re.search(r"\bmoles?\b", t).start() else "grams" + if want == "moles": + grams = nums[0] + return (f"{_fmt(grams)} grams of {name} is {grams/mass:.4g} moles, " + f"at {mass:.3f} grams per mole.") + moles = nums[0] + return (f"{_fmt(moles)} moles of {name} is {moles*mass:.4g} grams, " + f"at {mass:.3f} grams per mole.") + + # ── particle count ──────────────────────────────────────────────────────── + if re.search(r"\bhow many (?:atoms|molecules|particles)\b", t) and re.search(r"\bmoles?\b", t) and nums: + n = nums[0] * _CH_NA + return f"{_fmt(nums[0])} moles is {_ch_count(n)} particles, by Avogadro's number." + + # ── molarity ────────────────────────────────────────────────────────────── + if re.search(r"\bmolarity\b|\bmolar concentration\b", t) and len(nums) >= 2 and nums[1]: + return (f"{_fmt(nums[0])} moles in {_fmt(nums[1])} liters is " + f"{nums[0]/nums[1]:.4g} molar.") + + # ── pH ──────────────────────────────────────────────────────────────────── + if re.search(r"\bp\.?h\b", t) and nums and nums[0] > 0: + conc = nums[0] + # -log10 of the concentration. For an acid that IS the pH; for a hydroxide it + # is the pOH. Same arithmetic, two names, so the variable is named after the + # arithmetic — calling it `ph` and then using it as pOH was correct and read + # like a bug, which is its own kind of defect. + neg_log = -math.log10(conc) + kind = "acidic" if neg_log < 7 else "basic" if neg_log > 7 else "neutral" + if re.search(r"\bhydroxide\b|\bpoh\b|\bbase\b", t): + return (f"A {conc:.4g} molar hydroxide solution has a pOH of {_fmt(neg_log)}, " + f"so a pH of {_fmt(14-neg_log)}.") + return f"A {conc:.4g} molar solution has a pH of {_fmt(neg_log)}, which is {kind}." + + # ── ideal gas ───────────────────────────────────────────────────────────── + if re.search(r"\bideal gas\b|\bpv\s*=\s*nrt\b", t) or ( + re.search(r"\bmoles?\b", t) and re.search(r"\bkelvin\b", t) + and re.search(r"\batmospheres?\b|\batm\b", t)): + mo = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*moles?", t) + kv = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*kelvin", t) + at = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*(?:atmospheres?|atm)", t) + if mo and kv and at and float(at.group(1)): + n, tk, p = float(mo.group(1)), float(kv.group(1)), float(at.group(1)) + v = n * _CH_R * tk / p + atm_word = "atmosphere" if abs(p - 1) < 1e-9 else "atmospheres" + return (f"{_fmt(n)} moles at {_fmt(tk)} kelvin and {_fmt(p)} {atm_word} " + f"occupies {v:.4g} liters.") + + return None + + +# ── sci.py ───────────────────────────────────────────────────────────────── +# Astral sci — deterministic physics and astronomy. No LLM, no cloud, µs. +# +# Closed-form formulas over a table of measured constants. Every answer here is the +# same arithmetic a student does by hand, so it is either exactly right or it doesn't +# answer at all. Nothing is recalled from a model, which is the point: an LLM asked for +# the escape velocity of Mars produces a number that is usually close and occasionally +# invented. This produces sqrt(2GM/R) and says which body it used. +# +# • bodies "escape velocity of mars", "surface gravity of the moon", +# "how much would I weigh on mars at 180 pounds" +# • motion "how far does something fall in 3 seconds", +# "how fast after falling for 3 seconds" +# • mechanics "kinetic energy of 5 kilograms at 10 meters per second", +# "momentum of 5 kilograms at 3 meters per second", +# "force of 10 kilograms at 2 meters per second squared", +# "work done by 20 newtons over 5 meters", "power of 100 joules in 5 seconds" +# • circuits "voltage across 5 ohms with 2 amps" +# • relativity "time dilation at 0.9 c", "schwarzschild radius of the sun" +# • light "how long does light take to reach earth from the sun", +# "energy of a photon at 500 nanometers" +# +# handle(text) -> spoken string, or None to fall through. +# ── measured constants (CODATA / IAU) ───────────────────────────────────────── +_SCI_G = 6.67430e-11 # gravitational constant, m^3 kg^-1 s^-2 +_SCI_C = 299792458.0 # speed of light, m/s (exact) +_SCI_G0 = 9.80665 # standard gravity, m/s^2 (exact) +_SCI_H = 6.62607015e-34 # Planck, J s (exact) +_SCI_MSUN = 1.98847e30 # solar mass, kg +_SCI_LY = _LENGTH["light year"] # one definition, shared with the unit table +_SCI_MPS_TO_MPH = 2.2369362920544 # m/s -> mph, exact from the mile definition + +# body -> (mass kg, EQUATORIAL radius m, mean distance from Earth m or None) +# +# Equatorial, not volumetric-mean. This is not a detail: a gas giant is visibly +# oblate, and GM/R^2 with Jupiter's mean radius (69,911 km) gives 25.92 m/s^2 while +# every reference table a student will check says 24.79 — the value at the equatorial +# radius (71,492 km). Same for Saturn, 11.19 against a published 10.44. Using mean +# radii made this engine confidently disagree with the textbook on two headline +# numbers. With equatorial radii, every body agrees with the published surface gravity +# and escape velocity to better than half a percent, and test_hardening.py asserts it. +_SCI_BODY = { + "sun": (1.98847e30, 6.957e8, 1.495978707e11), + "mercury": (3.3011e23, 2.4405e6, 9.17e10), + "venus": (4.8675e24, 6.0518e6, 4.14e10), + "earth": (5.97217e24, 6.378137e6, None), + "moon": (7.342e22, 1.7381e6, 3.844e8), + "mars": (6.4171e23, 3.3962e6, 7.83e10), + "jupiter": (1.8982e27, 7.1492e7, 6.288e11), + "saturn": (5.6834e26, 6.0268e7, 1.275e12), + "uranus": (8.6810e25, 2.5559e7, 2.723e12), + "neptune": (1.02413e26, 2.4764e7, 4.351e12), + "pluto": (1.303e22, 1.1883e6, 5.75e12), +} +_SCI_ALIAS = {"the sun": "sun", "the moon": "moon", "the earth": "earth"} + +# Things light travels to, in metres. The solar-system distances are READ OFF the +# body table rather than restated here: writing 3.844e8 twice is one edit away from +# the Moon being two different distances depending on which question you asked. +_SCI_LIGHT_TARGET = {name: dist for name, (_m, _r, dist) in _SCI_BODY.items() if dist} +_SCI_LIGHT_TARGET.update({"the " + n: _SCI_LIGHT_TARGET[n] for n in ("sun", "moon")}) +_SCI_LIGHT_TARGET.update({ # beyond the solar system, so not in the body table + "alpha centauri": 4.1315e16, "proxima centauri": 3.996e16, + "sirius": 8.14e16, "the andromeda galaxy": 2.4e22, "andromeda": 2.4e22, + "the galactic center": 2.5e20, "the center of the galaxy": 2.5e20, +}) + + +def _sci_body(t: str): + for phrase, name in _SCI_ALIAS.items(): + if re.search(r"\b" + phrase + r"\b", t): + return name + for name in _SCI_BODY: + if re.search(r"\b" + name + r"\b", t): + return name + return None + + +def _sci_said(body: str) -> str: + """The Sun and the Moon take an article; the planets don't.""" + return "the " + body.title() if body in ("sun", "moon") else body.title() + + +def _sci_far(metres: float) -> str: + """Distance said usefully: kilometers up close, light years once that stops + meaning anything to a human ear.""" + if metres >= 1e15: + return f"{_fmt_spoken(metres/_SCI_LY)} light years" + return f"{_fmt_spoken(metres/1000)} kilometers" + + +def _sci_secs(seconds: float) -> str: + """Seconds said the way a person would say them.""" + if seconds < 1e-6: + return f"{seconds*1e9:.2f}".rstrip("0").rstrip(".") + " nanoseconds" + if seconds < 1e-3: + return f"{seconds*1e6:.2f}".rstrip("0").rstrip(".") + " microseconds" + if seconds < 1: + return f"{seconds*1e3:.2f}".rstrip("0").rstrip(".") + " milliseconds" + if seconds < 90: + return _fmt(seconds) + (" second" if abs(seconds - 1) < 1e-9 else " seconds") + if seconds < 5400: + return _fmt(seconds / 60) + " minutes" + if seconds < 172800: + return _fmt(seconds / 3600) + " hours" + if seconds < 3.156e7: + return _fmt(seconds / 86400) + " days" + return _fmt_spoken(seconds / 3.15576e7) + " years" + + +def sci_handle(text: str) -> str | None: + t = " " + text.lower().strip() + " " + nums = numbers(text) + body = _sci_body(t) + + # ── escape velocity ─────────────────────────────────────────────────────── + if "escape velocity" in t: + if body: + m, r, _ = _SCI_BODY[body] + v = math.sqrt(2 * _SCI_G * m / r) + return (f"Escape velocity at {_sci_said(body)} is {_fmt(v/1000)} kilometers per second, " + f"{_fmt(v*_SCI_MPS_TO_MPH)} miles per hour.") + return None + + # ── surface gravity ─────────────────────────────────────────────────────── + if re.search(r"\bsurface gravity\b|\bgravity (?:on|at|of)\b", t) and body: + m, r, _ = _SCI_BODY[body] + g = _SCI_G * m / (r * r) + return (f"Surface gravity on {_sci_said(body)} is {_fmt(g)} meters per second squared, " + f"{_fmt(g/_SCI_G0)} times Earth's.") + + # ── weight on another world ─────────────────────────────────────────────── + if re.search(r"\bweigh\b|\bweight\b", t) and body and body != "earth" and nums: + m, r, _ = _SCI_BODY[body] + ratio = (_SCI_G * m / (r * r)) / _SCI_G0 + unit = "pounds" if re.search(r"\bpounds?\b|\blbs?\b", t) else \ + "kilograms" if re.search(r"\bkilograms?\b|\bkg\b", t) else "units" + return (f"{_fmt(nums[0])} {unit} on Earth is {_fmt(nums[0]*ratio)} {unit} on " + f"{_sci_said(body)}, at {_fmt(ratio)} times Earth's gravity.") + + # ── Schwarzschild radius ────────────────────────────────────────────────── + if re.search(r"\bschwarzschild\b|\bevent horizon\b", t): + mass = None + if re.search(r"\bsolar mass(?:es)?\b", t) and nums: + mass, label = nums[0] * _SCI_MSUN, f"{_fmt(nums[0])} solar masses" + elif body: + mass, label = _SCI_BODY[body][0], _sci_said(body) + elif nums and re.search(r"\bkilograms?\b|\bkg\b", t): + mass, label = nums[0], f"{_fmt(nums[0])} kilograms" + if mass: + rs = 2 * _SCI_G * mass / (_SCI_C ** 2) + said = (f"{_fmt(rs/1000)} kilometers" if rs >= 1000 else + f"{_fmt(rs)} meters" if rs >= 0.01 else + f"{_fmt_spoken(rs)} meters") + return f"The Schwarzschild radius of {label} is {said}." + + # ── light travel time ───────────────────────────────────────────────────── + if re.search(r"\blight\b", t) and re.search(r"\btake\b|\btravel\b|\breach\b|\bget (?:to|from)\b", t): + for name, dist in sorted(_SCI_LIGHT_TARGET.items(), key=lambda kv: -len(kv[0])): + if re.search(r"\b" + re.escape(name) + r"\b", t): + said = name if name.startswith("the ") else \ + ("the " + name if name in ("sun", "moon") else name) + return (f"Light takes {_sci_secs(dist / _SCI_C)} to cross the " + f"{_sci_far(dist)} to {said}.") + return None + + # ── free fall ───────────────────────────────────────────────────────────── + if re.search(r"\bfall(?:s|ing)?\b|\bdropped?\b", t) and nums: + secs = nums[0] + if re.search(r"\bhow fast\b|\bvelocity\b|\bspeed\b", t): + v = _SCI_G0 * secs + return (f"After falling {_fmt(secs)} seconds it's going {_fmt(v)} meters per second, " + f"{_fmt(v*_SCI_MPS_TO_MPH)} miles per hour, ignoring air resistance.") + if re.search(r"\bhow far\b|\bdistance\b|\bfall\b", t): + d = 0.5 * _SCI_G0 * secs * secs + return (f"In {_fmt(secs)} seconds it falls {_fmt(d)} meters, " + f"{_fmt(d/0.3048)} feet, ignoring air resistance.") + + # ── kinetic / potential energy ──────────────────────────────────────────── + if "kinetic energy" in t and len(nums) >= 2: + m, v = nums[0], nums[1] + return f"The kinetic energy is {_fmt_spoken(0.5*m*v*v)} joules, from one half m v squared." + if re.search(r"\bpotential energy\b", t) and len(nums) >= 2: + m, h = nums[0], nums[1] + return f"The potential energy is {_fmt_spoken(m*_SCI_G0*h)} joules, from m g h." + + # ── momentum / force / work / power ─────────────────────────────────────── + if "momentum" in t and len(nums) >= 2: + return (f"The momentum is {_fmt_spoken(nums[0]*nums[1])} kilogram meters per second, " + f"from m v.") + if (re.search(r"\bforce\b", t) and len(nums) >= 2 and not re.search(r"\bpounds? force\b", t) + and re.search(r"\bnewtons?\b|\bkilograms?\b|\bacceleration\b|\bmeters per second squared\b", t)): + return f"The force is {_fmt_spoken(nums[0]*nums[1])} newtons, from m a." + if re.search(r"\bwork\b", t) and len(nums) >= 2 and re.search(r"\bnewtons?\b|\bjoules?\b", t): + return f"The work done is {_fmt_spoken(nums[0]*nums[1])} joules, from force times distance." + # "2 to the power of 8" also contains the word power; without the unit guard this + # branch answered "0.25 watts" and stole an existing golden. + if (re.search(r"\bpower\b", t) and len(nums) >= 2 and nums[1] + and re.search(r"\bjoules?\b|\bwatts?\b", t) and "to the power" not in t): + return f"The power is {_fmt_spoken(nums[0]/nums[1])} watts, from joules per second." + + # ── Ohm's law ───────────────────────────────────────────────────────────── + if re.search(r"\bohms?\b|\bamp(?:ere)?s?\b|\bvolts?\b", t) and len(nums) >= 2: + has_ohm = re.search(r"\bohms?\b", t) + has_amp = re.search(r"\bamp(?:ere)?s?\b", t) + has_volt = re.search(r"\bvolts?\b", t) + a, b = nums[0], nums[1] + if has_ohm and has_amp and not has_volt: + ohms = a if has_ohm.start() < has_amp.start() else b + amps = b if has_ohm.start() < has_amp.start() else a + return f"That's {_fmt(ohms*amps)} volts, from V equals I R." + if has_volt and has_ohm and not has_amp: + volts = a if has_volt.start() < has_ohm.start() else b + ohms = b if has_volt.start() < has_ohm.start() else a + if ohms: + return f"That's {_fmt(volts/ohms)} amps, from I equals V over R." + if has_volt and has_amp and not has_ohm: + volts = a if has_volt.start() < has_amp.start() else b + amps = b if has_volt.start() < has_amp.start() else a + if amps: + return f"That's {_fmt(volts/amps)} ohms, from R equals V over I." + + # ── time dilation ───────────────────────────────────────────────────────── + if re.search(r"\btime dilation\b|\blorentz factor\b|\bgamma at\b", t) and nums: + frac = nums[0] / 100 if nums[0] > 1 and "percent" in t else nums[0] + if 0 <= frac < 1: + gamma = 1 / math.sqrt(1 - frac * frac) + return (f"At {_fmt(frac)} times the speed of light the Lorentz factor is " + f"{_fmt(gamma)}, so moving clocks run {_fmt(gamma)} times slower.") + + # ── photon energy ───────────────────────────────────────────────────────── + if re.search(r"\bphoton\b", t) and nums: + nm = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*(?:nanometers?|nm)\b", t) + if nm: + lam = float(nm.group(1)) * 1e-9 + e = _SCI_H * _SCI_C / lam + return (f"A {_fmt(float(nm.group(1)))} nanometer photon carries {_fmt_spoken(e)} joules, " + f"{_fmt(e/1.602176634e-19)} electron volts.") + if re.search(r"\bhertz\b|\bhz\b", t): + e = _SCI_H * nums[0] + return f"That photon carries {_fmt_spoken(e)} joules." + + return None + + +# ── stats.py ─────────────────────────────────────────────────────────────── +# Astral stats — deterministic descriptive statistics and counting. No LLM, µs. +# +# • centre "average of 4 6 8 and 10", "median of 3 7 2 9", "mode of 2 2 5 7" +# • spread "standard deviation of 4 6 8 10", "variance of 4 6 8 10", +# "range of 3 7 2 9" +# • position "z score of 85 with a mean of 75 and a standard deviation of 5" +# • counting "5 choose 2", "permutations of 5 taken 2" +# +# Sample versus population is stated out loud rather than assumed silently — they are +# different numbers and a stats class grades you on which one you used. Ask for the +# population one by name and you get it. +# +# handle(text) -> spoken string, or None to fall through. +_ST_KEY = re.compile(r"\b(?:mean|average|median|mode|range|variance|" + r"standard deviation|std dev)\b") + + +def _st_list(text: str, t: str) -> list[float]: + """The data as SEPARATE values. + + calc.numbers() deliberately merges an adjacent run — "twenty five" is one number, + not two. A data list is the opposite problem: "4 6 8 10" is four numbers, and + merging them answers 28. So the list is tokenised here instead, with one English + concession: a tens word followed by a ones word ("twenty five") is still one + number, because nobody reads a list as "twenty, five" out loud. + """ + m = _ST_KEY.search(t) + tail = t[m.end():] if m else t + tail = re.sub(r"^\s*(?:of|for|is|are|was|were|the numbers?|these)\s+", " ", tail) + toks = [w for w in re.split(r"[^a-z0-9.]+", tail) if w] + vals, i = [], 0 + while i < len(toks): + w = toks[i] + if w.replace(".", "", 1).isdigit(): + vals.append(float(w)) + elif w in _TENS: + if i + 1 < len(toks) and toks[i + 1] in _ONES and toks[i + 1] not in ("a", "an"): + vals.append(float(_TENS[w] + _ONES[toks[i + 1]])) + i += 2 + continue + vals.append(float(_TENS[w])) + elif w in _ONES and w not in ("a", "an"): + vals.append(float(_ONES[w])) + i += 1 + return vals + + +def _st_said(vals: list[float]) -> str: + return ", ".join(_fmt(v) for v in vals) + + +def stats_handle(text: str) -> str | None: + t = " " + text.lower().strip() + " " + + # ── z score (checked before the plain mean/deviation words) ─────────────── + if re.search(r"\bz[- ]?score\b", t): + x = re.search(r"z[- ]?score (?:of|for) ([0-9]+(?:\.[0-9]+)?)", t) + mu = re.search(r"mean (?:of|is) ([0-9]+(?:\.[0-9]+)?)", t) + sd = re.search(r"(?:standard )?deviation (?:of|is) ([0-9]+(?:\.[0-9]+)?)", t) + if x and mu and sd and float(sd.group(1)): + z = (float(x.group(1)) - float(mu.group(1))) / float(sd.group(1)) + side = "above" if z >= 0 else "below" + return (f"The z score is {_fmt(z)}, {_fmt(abs(z))} standard deviations " + f"{side} the mean.") + return None + + # ── counting ────────────────────────────────────────────────────────────── + m = re.search(r"([0-9]+)\s+choose\s+([0-9]+)", t) + if m: + n, k = int(m.group(1)), int(m.group(2)) + if 0 <= k <= n <= 170: + return f"{n} choose {k} is {_fmt(math.comb(n, k))} combinations." + if re.search(r"\bpermutations?\b", t): + vals = numbers(text) + if len(vals) >= 2: + n, k = int(vals[0]), int(vals[1]) + if 0 <= k <= n <= 170: + return f"{n} things taken {k} at a time is {_fmt(math.perm(n, k))} permutations." + elif len(vals) == 1 and 0 <= vals[0] <= 170: + n = int(vals[0]) + return f"{n} things can be arranged {_fmt(math.factorial(n))} ways." + + # ── descriptive statistics ──────────────────────────────────────────────── + wants = None + if re.search(r"\b(?:mean|average)\b", t): + wants = "mean" + if re.search(r"\bmedian\b", t): + wants = "median" + if re.search(r"\bmode\b", t): + wants = "mode" + if re.search(r"\brange of\b", t): + wants = "range" + if re.search(r"\bvariance\b", t): + wants = "variance" + if re.search(r"\bstandard deviation\b|\bstd dev\b", t): + wants = "stdev" + if not wants: + return None + + vals = _st_list(text, t) + if len(vals) < 2: + return None + n = len(vals) + said = _st_said(vals) + + if wants == "mean": + return f"The mean of {said} is {_fmt(sum(vals)/n)}." + if wants == "median": + s = sorted(vals) + med = s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2 + return f"The median of {said} is {_fmt(med)}." + if wants == "mode": + counts = {} + for v in vals: + counts[v] = counts.get(v, 0) + 1 + top = max(counts.values()) + if top == 1: + return f"{said} has no mode — every value appears once." + modes = [v for v, c in counts.items() if c == top] + which = " and ".join(_fmt(v) for v in modes) + return (f"The mode of {said} is {which}, appearing {top} times." + if len(modes) == 1 else + f"{said} is multimodal: {which}, each appearing {top} times.") + if wants == "range": + return f"The range of {said} is {_fmt(max(vals) - min(vals))}, from {_fmt(min(vals))} to {_fmt(max(vals))}." + + mean = sum(vals) / n + ss = sum((v - mean) ** 2 for v in vals) + population = bool(re.search(r"\bpopulation\b", t)) + div = n if population else n - 1 + var = ss / div + label = "population" if population else "sample" + if wants == "variance": + return f"The {label} variance of {said} is {_fmt(var)}." + return (f"The {label} standard deviation of {said} is {_fmt(math.sqrt(var))}, " + f"around a mean of {_fmt(mean)}.") + + +# ── mathx.py ─────────────────────────────────────────────────────────────── +# Astral mathx — the rest of the deterministic math. No LLM, no cloud, µs. +# +# Everything here is a closed-form answer a calculator would give, which is exactly the +# class a language model should never be asked for: +# +# • bases "42 in binary", "255 in hexadecimal", "binary 1011 in decimal" +# • logs "log of 1000", "natural log of 10", "log base 2 of 64" +# • trig "sine of 30 degrees", "cosine of 60 degrees" +# • number theory "greatest common factor of 12 and 18", "least common multiple of 4 and 6", +# "is 91 prime", "prime factors of 360", "17 mod 5" +# • algebra "solve the quadratic 1 5 6" +# • percent "what percent of 80 is 20", "percent change from 40 to 50" +# • fractions "3 over 8 as a decimal", "0.375 as a fraction", "simplify 18 over 24" +# • rounding "round 3.14159 to 3 significant figures", +# "write 0.00045 in scientific notation" +# +# handle(text) -> spoken string, or None to fall through. +_MX_DIGITS = re.compile(r"-?\d+(?:\.\d+)?") + + +def _mx_fmt(x: float) -> str: + """Six significant digits. calc._fmt stops at two decimals, which is right for + money and wrong for a logarithm: ln 10 is 2.3026, not 2.3. + + Rounding to six places also turns anything under 1e-6 into a flat "0" — the same + say-zero-about-a-non-zero-number failure _fmt had. The sine of a very small angle + is small, not nothing.""" + # Two different tiny things. sin(180 degrees) is EXACTLY zero and comes back as + # 1.22e-16 — float residue from the identity, and reading that out as an answer is + # noise pretending to be precision. The sine of a genuinely tiny angle is a real + # small number and must not be flattened to "0". The line between them sits well + # below anything a person says out loud and well above double-precision residue. + if abs(x) < 1e-12: + return "0" + if abs(x) < 1e-6: + return f"{x:.4g}".replace("e-0", " times ten to the minus ").replace( + "e-", " times ten to the minus ") + return f"{round(x, 6):g}" + + +def _mx_seq(t: str) -> list[float]: + """Digit tokens, kept separate. calc.numbers() merges "1 5 6" into 12, which is + right for spoken quantities and wrong for a list of coefficients.""" + return [float(x) for x in _MX_DIGITS.findall(t)] + + +def _mx_factors(n: int) -> list[int]: + out, d = [], 2 + while d * d <= n: + while n % d == 0: + out.append(d) + n //= d + d += 1 if d == 2 else 2 + if n > 1: + out.append(n) + return out + + +def _mx_said_factors(fs: list[int]) -> str: + groups, out = {}, [] + for f in fs: + groups[f] = groups.get(f, 0) + 1 + for base, power in groups.items(): + out.append(f"{base}" if power == 1 else f"{base} to the {power}") + return " times ".join(out) + + +def mathx_handle(text: str) -> str | None: + t = " " + text.lower().strip() + " " + + # ── number bases ────────────────────────────────────────────────────────── + m = re.search(r"\b(?:binary|base two)\s+([01]+)\b", t) + if m and re.search(r"\bdecimal\b|\bbase ten\b|\bin decimal\b", t): + return f"Binary {m.group(1)} is {int(m.group(1), 2)} in decimal." + m = re.search(r"\b(?:hex|hexadecimal|base sixteen)\s+([0-9a-f]+)\b", t) + if m and re.search(r"\bdecimal\b|\bbase ten\b", t): + return f"Hex {m.group(1).upper()} is {int(m.group(1), 16)} in decimal." + m = re.search(r"([0-9]+)\s+(?:in|to|as)\s+(binary|hex|hexadecimal|octal|base two|base sixteen|base eight)\b", t) + if m: + n, base = int(m.group(1)), m.group(2) + if base in ("binary", "base two"): + return f"{n} in binary is {bin(n)[2:]}." + if base in ("octal", "base eight"): + return f"{n} in octal is {oct(n)[2:]}." + return f"{n} in hexadecimal is {hex(n)[2:].upper()}." + + # ── logarithms ──────────────────────────────────────────────────────────── + m = re.search(r"log(?:arithm)?\s+base\s+([0-9]+(?:\.[0-9]+)?)\s+of\s+([0-9]+(?:\.[0-9]+)?)", t) + if m: + b, x = float(m.group(1)), float(m.group(2)) + if b > 0 and b != 1 and x > 0: + return f"Log base {_fmt(b)} of {_fmt(x)} is {_mx_fmt(math.log(x, b))}." + m = re.search(r"\bnatural log(?:arithm)?\s+(?:of\s+)?([0-9]+(?:\.[0-9]+)?)", t) + if m and float(m.group(1)) > 0: + return f"The natural log of {_fmt(float(m.group(1)))} is {_mx_fmt(math.log(float(m.group(1))))}." + m = re.search(r"\blog(?:arithm)?\s+(?:of\s+)?([0-9]+(?:\.[0-9]+)?)", t) + if m and float(m.group(1)) > 0: + return f"The log of {_fmt(float(m.group(1)))} is {_mx_fmt(math.log10(float(m.group(1))))}, base ten." + + # ── trigonometry ────────────────────────────────────────────────────────── + m = re.search(r"\b(sine|sin|cosine|cos|tangent|tan)\s+(?:of\s+)?(-?[0-9]+(?:\.[0-9]+)?)", t) + if m: + fn, v = m.group(1), float(m.group(2)) + radians = bool(re.search(r"\bradians?\b", t)) + ang = v if radians else math.radians(v) + unit = "radians" if radians else "degrees" + if fn in ("tangent", "tan") and abs(math.cos(ang)) < 1e-12: + return f"The tangent of {_fmt(v)} {unit} is undefined — the cosine is zero there." + val = (math.sin(ang) if fn in ("sine", "sin") else + math.cos(ang) if fn in ("cosine", "cos") else math.tan(ang)) + name = {"sin": "sine", "cos": "cosine", "tan": "tangent"}.get(fn, fn) + return f"The {name} of {_fmt(v)} {unit} is {_mx_fmt(val)}." + + # ── number theory ───────────────────────────────────────────────────────── + if re.search(r"\bgreatest common (?:factor|divisor)\b|\bgcf\b|\bgcd\b", t): + vals = _mx_seq(t) + if len(vals) >= 2: + a, b = int(vals[0]), int(vals[1]) + return f"The greatest common factor of {a} and {b} is {math.gcd(a, b)}." + if re.search(r"\bleast common multiple\b|\blcm\b", t): + vals = _mx_seq(t) + if len(vals) >= 2: + a, b = int(vals[0]), int(vals[1]) + if a and b: + return f"The least common multiple of {a} and {b} is {a*b//math.gcd(a, b)}." + m = re.search(r"\bis\s+([0-9]+)\s+(?:a\s+)?prime\b", t) + if m: + n = int(m.group(1)) + fs = _mx_factors(n) if n > 1 else [] + if n < 2: + return f"{n} is not prime — primes start at 2." + if len(fs) == 1: + return f"Yes, {n} is prime." + return f"No, {n} isn't prime. It's {_mx_said_factors(fs)}." + if re.search(r"\bprime factor", t): + vals = _mx_seq(t) + if vals and 1 < vals[0] <= 1e12: + n = int(vals[0]) + return f"The prime factors of {n} are {_mx_said_factors(_mx_factors(n))}." + m = re.search(r"([0-9]+)\s*(?:mod|modulo|modulus)\s*([0-9]+)", t) + if m and int(m.group(2)): + a, b = int(m.group(1)), int(m.group(2)) + return f"{a} mod {b} is {a % b}." + + # ── quadratic ───────────────────────────────────────────────────────────── + if re.search(r"\bquadratic\b", t): + vals = _mx_seq(t) + if len(vals) >= 3 and vals[0]: + a, b, c = vals[0], vals[1], vals[2] + disc = b * b - 4 * a * c + if disc < 0: + return (f"With a {_fmt(a)}, b {_fmt(b)}, c {_fmt(c)} the discriminant is " + f"{_fmt(disc)}, so there are no real roots.") + r1 = (-b + math.sqrt(disc)) / (2 * a) + r2 = (-b - math.sqrt(disc)) / (2 * a) + if disc == 0: + return f"With a {_fmt(a)}, b {_fmt(b)}, c {_fmt(c)} there's one root, x equals {_fmt(r1)}." + return (f"With a {_fmt(a)}, b {_fmt(b)}, c {_fmt(c)} the roots are " + f"{_fmt(r1)} and {_fmt(r2)}.") + + # ── percent relationships ───────────────────────────────────────────────── + m = re.search(r"what percent of\s+([0-9]+(?:\.[0-9]+)?)\s+is\s+([0-9]+(?:\.[0-9]+)?)", t) + if m and float(m.group(1)): + whole, part = float(m.group(1)), float(m.group(2)) + return f"{_fmt(part)} is {_fmt(part/whole*100)} percent of {_fmt(whole)}." + m = re.search(r"percent (?:change|increase|decrease|difference)\s+from\s+([0-9]+(?:\.[0-9]+)?)\s+to\s+([0-9]+(?:\.[0-9]+)?)", t) + if m and float(m.group(1)): + a, b = float(m.group(1)), float(m.group(2)) + pct = (b - a) / a * 100 + word = "increase" if pct >= 0 else "decrease" + return f"From {_fmt(a)} to {_fmt(b)} is a {_fmt(abs(pct))} percent {word}." + + # ── fractions ───────────────────────────────────────────────────────────── + m = re.search(r"([0-9]+)\s*(?:over|/|divided by)\s*([0-9]+)\s*(?:as a decimal|in decimal)", t) + if m and int(m.group(2)): + a, b = int(m.group(1)), int(m.group(2)) + return f"{a} over {b} is {round(a/b, 6):g} as a decimal." + m = re.search(r"(?:simplify|reduce)\s+([0-9]+)\s*(?:over|/)\s*([0-9]+)", t) + if m and int(m.group(2)): + fr = Fraction(int(m.group(1)), int(m.group(2))) + if fr.denominator == 1: + return f"{m.group(1)} over {m.group(2)} simplifies to {fr.numerator}." + return f"{m.group(1)} over {m.group(2)} simplifies to {fr.numerator} over {fr.denominator}." + m = re.search(r"([0-9]*\.[0-9]+)\s+as a fraction", t) + if m: + fr = Fraction(m.group(1)).limit_denominator(10000) + return f"{m.group(1)} as a fraction is {fr.numerator} over {fr.denominator}." + + # ── rounding and notation ───────────────────────────────────────────────── + m = re.search(r"round\s+(-?[0-9]+(?:\.[0-9]+)?)\s+to\s+([0-9]+)\s+(significant figures?|sig figs?|decimal places?|decimals?)", t) + if m: + x, k, kind = float(m.group(1)), int(m.group(2)), m.group(3) + if kind.startswith("sig"): + if x == 0: + return "Zero to any number of significant figures is 0." + r = round(x, -int(math.floor(math.log10(abs(x)))) + (k - 1)) + return f"{m.group(1)} to {k} significant figures is {r:g}." + return f"{m.group(1)} to {k} decimal places is {round(x, k):.{k}f}." + m = re.search(r"(-?[0-9]*\.?[0-9]+)\s+in scientific notation", t) + if m: + x = float(m.group(1)) + if x != 0: + exp = math.floor(math.log10(abs(x))) + mant = x / (10 ** exp) + return (f"{m.group(1)} in scientific notation is {round(mant, 6):g} times ten " + f"to the {'minus ' if exp < 0 else ''}{abs(exp)}.") + + return None + + +# ── engine.py ────────────────────────────────────────────────────────────── +# Astral engine — the one router every surface uses. +# +# `answer(text, now)` is the whole contract: a spoken string when Astral is certain, and +# None when it isn't, so the agent takes the turn. There is exactly one routing order and +# it lives here — the DevKit ability, the cloud Skill and the tests all run this same +# function, so a phrase can never resolve one way on the device and another way in the +# cloud. +# +# ORDER IS BEHAVIOUR. Specific domains run before general arithmetic, because the +# general one will happily match a fragment of a specific question: "2 to the power of 8" +# contains the word power, "how many grams in 2 moles" contains a unit. Each module is +# responsible for returning None fast when the question isn't its business; moving a +# module up this list without re-running the goldens is how a wrong answer ships. +# Specific -> general. Time and date first: they are the cheapest and the most +# unambiguous. calc last: it is the catch-all for plain arithmetic and conversions. +# +# ONE list. There used to be two — a _ROUTE_ORDER tuple of names that domains() and +# the tests read, and a separate hardcoded tuple of functions that actually did the +# routing. Reordering either one left the other still claiming the old order, which +# means the test asserting the route order could pass while the real order had +# changed. A test that can lie about the thing it guards is worse than no test. +# The flag is whether the module takes the clock; only time and date does. +_ROUTE = ( + ("mechanical", mech_handle, True), + ("study", study_handle, False), + ("chem", chem_handle, False), + ("sci", sci_handle, False), + ("stats", stats_handle, False), + ("mathx", mathx_handle, False), + ("calc", calc_handle, False), +) + + +def normalize(text: str) -> str: + """Clean what speech-to-text actually hands over, not what a test types. + + Whisper punctuates. It returns "What is 20% of 80?" and "What letter grade is an + 87?", and a trailing question mark or a percent sign is enough to stop the number + patterns matching — so the engine answered both of those on clean text and neither + of them out loud. The cloud Skill had a normalizer; the device file never did, so + the two surfaces disagreed on the one input that actually occurs. + + Deliberately gentle. An earlier version of this lowercased everything and stripped + every non-word character, which would take Ca(OH)2 apart — the chemistry parser + needs both the capitals and the parentheses. So this only touches the symbols + speech-to-text substitutes for words, and sentence-final punctuation. + """ + text = (text or "").replace("%", " percent ").replace("$", " dollars ") + text = text.replace("°", " degrees ").replace("’", "'") + text = re.sub(r"[?!,;:]", " ", text) + text = re.sub(r"\.(?=\s|$)", " ", text) # sentence dots, not decimal points + return re.sub(r"\s+", " ", text).strip() + + +def astral_answer(text: str, now: datetime | None = None) -> str | None: + if not text or not text.strip(): + return None + text = normalize(text) + if not text: + return None + for _name, fn, takes_clock in _ROUTE: + r = fn(text, now) if takes_clock else fn(text) + if r: + return r + return None + + +def domains() -> tuple: + """The routing order, read off the thing that actually routes.""" + return tuple(name for name, _fn, _clock in _ROUTE) + +# ===== END ASTRAL ENGINE ===== + +# ============================ Astral capability ============================ + + +class AstralCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + # {{register capability}} + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self.run()) + + async def run(self): + try: + # No local normalizer: astral_answer() cleans the transcript itself, so the + # device and the cloud agree on what a spoken sentence means. The one that + # used to live here lowercased and stripped every symbol, which turned + # Ca(OH)2 into 'ca oh 2' and broke every chemistry formula in this build. + msg = await self.capability_worker.wait_for_complete_transcription() + if not msg: + return + tz = self.capability_worker.get_timezone() + now = datetime.now(ZoneInfo(tz)) if tz else datetime.now() + + # One router, same order as the device: time and date first (in the + # agent's timezone), then grades, chemistry, physics, statistics, number + # tools, and plain arithmetic last. + answer = astral_answer(msg, now) + if answer: + await self.capability_worker.speak(answer) + # else: not an exact-answer question -> stay quiet, the agent takes it + except Exception as error: + self.worker.editor_logging_handler.error(f"Astral failed: {error}") + finally: + self.capability_worker.resume_normal_flow() diff --git a/community/astral/requirements.txt b/community/astral/requirements.txt new file mode 100644 index 00000000..74372fcc --- /dev/null +++ b/community/astral/requirements.txt @@ -0,0 +1 @@ +# Python standard library only. No external packages.