"""Exact arithmetic over a list of numbers. **Why this earns its place next to ``calculate@1``.** The strongest result in the tool-use literature is the calculator one: Toolformer took a 6.7B model from 5.2 to 29.4 per cent on SVAMP, past a 175B model working without one, while the same paper's search tool failed to close the gap on factual questions. A tool that computes an exact answer to a subproblem buys a great deal at this scale. :mod:`distinct_tools.local` already publishes ``calculate``, which evaluates an expression string, so the honest question is what a second arithmetic tool adds. It is the shape of the argument, and nothing else. To total forty numbers with ``calculate`` the model must transcribe all forty into one expression, and a transcription of forty numbers is forty chances to drop a digit that nothing downstream can detect. Here they arrive as a list, the tool reports how many it used, and a dropped entry shows up as a count that does not match. It also answers "middle", "smallest" and "largest" in the same call, which as an expression would need the model to order the list first: exactly the operation it is worst at. That is an argument from failure modes, not a measurement. Nothing in this project has measured either tool against the other on a small model, and this module does not claim one. **Exactness is decimal, and inexactness is reported rather than hidden.** Values are held as :class:`decimal.Decimal` in a bounded context, so a column of prices totals to the penny instead of to the nearest float. A mean that does not divide exactly is rounded, and the result says so, because a number presented as exact when it is not is worse than a number presented as approximate. Like everything else in the package it answers in one call, opens no socket, reads no file and spawns no process. """ from __future__ import annotations import decimal from collections.abc import Mapping, Sequence from decimal import Decimal from typing import Any from distinct_tools.core import ToolInputError from distinct_tools.mcp import InProcessMcpServer, McpServerManifest, McpToolDefinition from .common import reject_unknown, structured MAX_NUMBERS = 200 #: Largest magnitude accepted. Bounded so that formatting a total without an #: exponent cannot produce an enormous string, which is the one way a fixed #: number of inputs turns into unbounded output here. MAX_MAGNITUDE = Decimal("1e15") #: Matches the precision :mod:`distinct_tools.local` uses: enough to be exact #: for money and ordinary measurement, small enough that no single operation is #: expensive. PRECISION = 34 #: The manifest an operator would approve to install this server. NUMBERS_MANIFEST = McpServerManifest(slug="numbers", version="1", timeout_seconds=5.0) def _context() -> decimal.Context: """A bounded arithmetic context that traps rather than returning a surprise.""" return decimal.Context( prec=PRECISION, Emax=9_999, Emin=-9_999, traps=[decimal.InvalidOperation, decimal.DivisionByZero, decimal.Overflow], ) def _to_decimal(value: object, position: int) -> Decimal: """Convert one entry, or refuse and name which entry was wrong.""" if isinstance(value, bool): raise ToolInputError(f"entry {position} is true or false, which is not a number") if isinstance(value, int | float): text = repr(value) elif isinstance(value, str): text = value.strip() if not text: raise ToolInputError(f"entry {position} is empty") if "," in text: # Refused rather than stripped: a comma means a thousands separator # in one convention and a decimal point in another, and guessing # which would silently change the answer by a factor of a thousand. raise ToolInputError( f"entry {position} contains a comma; write numbers without separators, " "for example 1234.50" ) else: raise ToolInputError(f"entry {position} is not a number") try: number = Decimal(text) except (decimal.InvalidOperation, ValueError) as exc: raise ToolInputError(f"entry {position} is not a number: {str(value)[:40]!r}") from exc if not number.is_finite(): raise ToolInputError(f"entry {position} is not a finite number") if abs(number) > MAX_MAGNITUDE: raise ToolInputError(f"entry {position} is larger than this tool accepts") return number def _render(value: Decimal) -> str: """Render exactly, without an exponent, without a trailing zero run.""" normalised = value.normalize() if normalised == 0: return "0" text = format(normalised, "f") if "." in text: text = text.rstrip("0").rstrip(".") return text or "0" def _summarise(arguments: Mapping[str, Any]) -> dict[str, Any]: reject_unknown(arguments, frozenset({"numbers"})) raw = arguments.get("numbers") if not isinstance(raw, Sequence) or isinstance(raw, str | bytes): raise ToolInputError("numbers must be a list") if not raw: raise ToolInputError("numbers must contain at least one entry") if len(raw) > MAX_NUMBERS: raise ToolInputError(f"numbers must contain at most {MAX_NUMBERS} entries") values = [_to_decimal(item, index) for index, item in enumerate(raw, start=1)] count = len(values) try: with decimal.localcontext(_context()): total = sum(values, Decimal(0)) mean = total / count ordered = sorted(values) middle = count // 2 if count % 2: median = ordered[middle] median_exact = True else: pair = ordered[middle - 1] + ordered[middle] median = pair / 2 median_exact = median * 2 == pair exact = median_exact and mean * count == total rendered = { "sum": _render(total), "mean": _render(mean), "median": _render(median), "minimum": _render(ordered[0]), "maximum": _render(ordered[-1]), } except decimal.DecimalException as exc: raise ToolInputError("these numbers could not be summarised exactly") from exc return structured( { "count": count, **rendered, # Said plainly rather than left to be assumed: the sum, smallest # and largest are always exact, and this reports whether the mean # and middle survived division without rounding. "exact": bool(exact), } ) SUMMARISE_DEFINITION = McpToolDefinition( name="summarise_numbers", description=( "Work out the total, the mean, the middle value, the smallest and the largest of a list " "of numbers, exactly. Use it when you have a column or a list of numbers and the user " "asked for any of those: adding numbers up by reading them is where mistakes happen. " "Pass the numbers as a list, not as one piece of text, and it will tell you how many it " "used so you can check none was missed. For a single sum with brackets, powers or " "percentages use calculate instead. It does not interpret what the numbers mean and " "cannot tell you which of them matters." ), input_schema={ "type": "object", "additionalProperties": False, "required": ["numbers"], "properties": { "numbers": { "type": "array", "minItems": 1, "maxItems": MAX_NUMBERS, "description": "the numbers, written without thousands separators", "items": {"type": ["number", "string"]}, } }, }, ) def numbers_mcp_server() -> InProcessMcpServer: """An in-process MCP server publishing ``summarise_numbers``. Install it with ``install_mcp_server(registry, numbers_mcp_server(), NUMBERS_MANIFEST)``, which produces ``numbers.summarise_numbers@1``. """ server = InProcessMcpServer() server.add_tool(SUMMARISE_DEFINITION, _summarise) return server __all__ = [ "MAX_NUMBERS", "NUMBERS_MANIFEST", "SUMMARISE_DEFINITION", "numbers_mcp_server", ]