AXLR / Catalog / mathematics

sequence_tools

Recognise a number sequence from its terms: arithmetic, geometric, polynomial by finite differences, or a linear recurrence with constant coefficients solved exactly over fractions. Gives the next terms, the closed form where one exists, and how many terms fix the model versus how many merely confirm it, never claiming more than the data supports. Squares, triangular numbers, Fibonacci and 2^n.

Install

/tools install sequence_tools
/tools install sequence_tools@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · sequence_tools 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool sequence_tools; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
termsarray of ['number', 'string']yesNumbers, decimal or fraction strings. items ≥ 3; items ≤ 200
nextintegerno5≥ 0; ≤ 100
start_indexintegerno1The index n of the first term
Input schema (JSON)
{
  "type": "object",
  "required": [
    "terms"
  ],
  "additionalProperties": false,
  "properties": {
    "terms": {
      "type": "array",
      "minItems": 3,
      "maxItems": 200,
      "items": {
        "type": [
          "number",
          "string"
        ]
      },
      "description": "Numbers, decimal or fraction strings"
    },
    "next": {
      "type": "integer",
      "minimum": 0,
      "maximum": 100,
      "default": 5
    },
    "start_index": {
      "type": "integer",
      "default": 1,
      "description": "The index n of the first term"
    }
  }
}

Example

Input

{
  "terms": [
    1,
    1,
    2,
    3,
    5,
    8,
    13,
    21
  ],
  "next": 3
}

Output (stdout)

{
  "model": "linear_recurrence",
  "recurrence": "a(n) = a(n-1) + a(n-2)",
  "next": [
    "34",
    "55",
    "89"
  ],
  "checked_on": 4
}

Files

The command the install card registers is python3 sequence_tools.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

sequence_tools.py · sha256 6c507f347f7f52e9c23dd9d73b0d3f05fa217903585695c79e3285935ced9da4
#!/usr/bin/env python3
"""Recognise a number sequence from its terms: arithmetic, geometric, polynomial (finite
differences) or a linear recurrence with constant coefficients, solved exactly over Fractions.
The simplest model that the given terms over-determine wins (fewest terms needed to fix it,
with at least one more term to check it); the answer says how many terms fix the model and how
many confirm it, and claims nothing beyond the given terms. Next terms and, where there is one,
a closed form in n.

Input (JSON on stdin):
  terms        [numbers or decimal/fraction strings], 3 to 200
  next         how many further terms to produce, default 5 (0 to 100)
  start_index  the index n of the first term, default 1
"""
import json
import math
import sys
from fractions import Fraction

if hasattr(sys, "set_int_max_str_digits"):
    sys.set_int_max_str_digits(0)

MAX_TERMS = 200
MAX_NEXT = 100
MAX_ORDER = 10


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def read_terms(values):
    if not isinstance(values, list) or not 3 <= len(values) <= MAX_TERMS:
        fail(f"terms must be a list of 3 to {MAX_TERMS} numbers")
    out = []
    for i, v in enumerate(values):
        if isinstance(v, bool) or not isinstance(v, (int, float, str)):
            fail(f"terms[{i}] is not a number")
        try:
            out.append(Fraction(str(v).strip()))
        except (ValueError, ZeroDivisionError):
            fail(f"terms[{i}] is not a number")
    return out


def fmt(c):
    return str(c)


def paren(c):
    return f"({c})" if c < 0 or c.denominator != 1 else str(c)


def poly_string(coefs):
    """coefs[k] multiplies n^k."""
    parts = []
    for k in range(len(coefs) - 1, -1, -1):
        c = coefs[k]
        if c == 0:
            continue
        mono = "" if k == 0 else ("n" if k == 1 else f"n^{k}")
        mag = abs(c)
        body = str(mag) if not mono else (mono if mag == 1 else f"{mag}*{mono}")
        if not parts:
            parts.append(("-" if c < 0 else "") + body)
        else:
            parts.append(("- " if c < 0 else "+ ") + body)
    return " ".join(parts) if parts else "0"


def solve_linear(m, rhs):
    """Unique solution of a square system over Fractions, or None when singular."""
    k = len(m)
    a = [row[:] + [r] for row, r in zip(m, rhs)]
    for c in range(k):
        p = next((i for i in range(c, k) if a[i][c] != 0), None)
        if p is None:
            return None
        a[c], a[p] = a[p], a[c]
        pv = a[c][c]
        a[c] = [v / pv for v in a[c]]
        for i in range(k):
            if i != c and a[i][c] != 0:
                f = a[i][c]
                a[i] = [x - f * y for x, y in zip(a[i], a[c])]
    return [a[i][k] for i in range(k)]


def binom_poly(shift, k):
    """Coefficients of C(n - shift, k) as a polynomial in n."""
    coefs = [Fraction(1)]
    for j in range(k):
        # multiply by (n - shift - j) / (j + 1)
        new = [Fraction(0)] * (len(coefs) + 1)
        for i, c in enumerate(coefs):
            new[i + 1] += c / (j + 1)
            new[i] += c * (-shift - j) / (j + 1)
        coefs = new
    return coefs


def polynomial_fit(terms, start, degree):
    """(coefficients in n) if the terms fit a polynomial of exactly this degree, else None."""
    rows, diffs = [terms[:]], terms[:]
    for _ in range(degree):
        diffs = [b - a for a, b in zip(diffs, diffs[1:])]
        rows.append(diffs)
    last = rows[degree]
    if len(last) < 2 or any(v != last[0] for v in last) or last[0] == 0:
        return None
    coefs = [Fraction(0)] * (degree + 1)
    for k in range(degree + 1):
        for i, c in enumerate(binom_poly(start, k)):
            coefs[i] += rows[k][0] * c
    return coefs


def poly_value(coefs, n):
    return sum(c * Fraction(n) ** k for k, c in enumerate(coefs))


def recurrence_fit(terms, order):
    if 2 * order + 1 > len(terms):
        return None
    m = [[terms[i - j] for j in range(1, order + 1)] for i in range(order, 2 * order)]
    coefs = solve_linear(m, terms[order:2 * order])
    if coefs is None or all(c == 0 for c in coefs):
        return None
    for i in range(2 * order, len(terms)):
        if terms[i] != sum(c * terms[i - j] for j, c in enumerate(coefs, 1)):
            return None
    return coefs


def recurrence_string(coefs):
    parts = []
    for j, c in enumerate(coefs, 1):
        if c == 0:
            continue
        body = f"a(n-{j})" if abs(c) == 1 else f"{abs(c)}*a(n-{j})"
        parts.append((("-" if c < 0 else "") + body) if not parts else (("- " if c < 0 else "+ ") + body))
    return "a(n) = " + " ".join(parts)


def rational_sqrt(q):
    if q < 0:
        return None
    a, b = math.isqrt(q.numerator), math.isqrt(q.denominator)
    return Fraction(a, b) if a * a == q.numerator and b * b == q.denominator else None


def characteristic(coefs):
    parts = [f"x^{len(coefs)}"]
    for j, c in enumerate(coefs, 1):
        k = len(coefs) - j
        if c == 0:
            continue
        mono = "" if k == 0 else ("x" if k == 1 else f"x^{k}")
        mag = abs(c)
        body = str(mag) if not mono else (mono if mag == 1 else f"{mag}*{mono}")
        parts.append(("+ " if c < 0 else "- ") + body)
    return " ".join(parts)


def order_two_closed_form(coefs, terms, start):
    """a(n) for a(n) = c1 a(n-1) + c2 a(n-2) when the characteristic roots are rational."""
    c1, c2 = coefs
    disc = c1 * c1 + 4 * c2
    root = rational_sqrt(disc)
    if root is None:
        kind = "complex" if disc < 0 else "irrational"
        return None, f"characteristic roots are {kind}: ({c1} +- sqrt({disc}))/2"
    if disc == 0:
        r = c1 / 2
        if r == 0:
            return None, None
        ab = solve_linear([[r ** start, start * r ** start], [r ** (start + 1), (start + 1) * r ** (start + 1)]], terms[:2])
        pieces = [(ab[0], r, ""), (ab[1], r, "n*")]
    else:
        r1, r2 = (c1 + root) / 2, (c1 - root) / 2
        if r1 == 0 or r2 == 0:
            return None, None
        ab = solve_linear([[r1 ** start, r2 ** start], [r1 ** (start + 1), r2 ** (start + 1)]], terms[:2])
        pieces = [(ab[0], r1, ""), (ab[1], r2, "")]
    parts = []
    for coef, base, n_factor in pieces:
        if coef == 0:
            continue
        power = "" if base == 1 else f"{paren(base)}^n"
        factor = (n_factor + power).rstrip("*") or ""
        mag = abs(coef)
        if factor:
            body = factor if mag == 1 else f"{mag}*{factor}"
        else:
            body = str(mag)
        parts.append((("-" if coef < 0 else "") + body) if not parts else (("- " if coef < 0 else "+ ") + body))
    return "a(n) = " + (" ".join(parts) if parts else "0"), None


def candidates(terms, start):
    n = len(terms)
    found = []
    d = terms[1] - terms[0]
    if all(b - a == d for a, b in zip(terms, terms[1:])):
        coefs = [terms[0] - d * start, d]
        found.append({"model": "arithmetic" if d else "constant", "determined_by": 2, "difference": fmt(d),
                      "closed_form": "a(n) = " + poly_string(coefs), "_next": lambda k, c=coefs: poly_value(c, k)})
    if all(t != 0 for t in terms):
        r = terms[1] / terms[0]
        if r != 1 and all(b == a * r for a, b in zip(terms, terms[1:])):
            const = terms[0] / r ** start
            power = f"{paren(r)}^n"
            closed = power if const == 1 else (f"-{power}" if const == -1 else f"{paren(const)} * {power}")
            found.append({"model": "geometric", "determined_by": 2, "ratio": fmt(r), "closed_form": "a(n) = " + closed,
                          "_next": lambda k, c=const, r=r: c * r ** k})
    for degree in range(2, n - 1):
        coefs = polynomial_fit(terms, start, degree)
        if coefs is not None:
            found.append({"model": "polynomial", "degree": degree, "determined_by": degree + 1,
                          "coefficients": [fmt(c) for c in coefs], "closed_form": "a(n) = " + poly_string(coefs),
                          "_next": lambda k, c=coefs: poly_value(c, k)})
            break
    for order in range(2, min(MAX_ORDER, (n - 1) // 2) + 1):
        coefs = recurrence_fit(terms, order)
        if coefs is not None:
            closed, note = order_two_closed_form(coefs, terms, start) if order == 2 else (None, None)
            model = {"model": "linear_recurrence", "order": order, "determined_by": 2 * order,
                     "coefficients": [fmt(c) for c in coefs], "recurrence": recurrence_string(coefs),
                     "initial_terms": [fmt(t) for t in terms[:order]], "characteristic_polynomial": characteristic(coefs),
                     "closed_form": closed, "_rec": coefs}
            if note:
                model["closed_form_note"] = note
            found.append(model)
            break
    return found


def extend(model, terms, start, count):
    if "_rec" in model:
        seq, coefs = terms[:], model["_rec"]
        for _ in range(count):
            seq.append(sum(c * seq[-j] for j, c in enumerate(coefs, 1)))
        return seq[len(terms):]
    return [model["_next"](start + len(terms) + i) for i in range(count)]


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    terms = read_terms(request.get("terms"))
    count = request.get("next", 5)
    if isinstance(count, bool) or not isinstance(count, int) or not 0 <= count <= MAX_NEXT:
        fail(f"next must be an integer from 0 to {MAX_NEXT}")
    start = request.get("start_index", 1)
    if isinstance(start, bool) or not isinstance(start, int) or abs(start) > 10 ** 6:
        fail("start_index must be an integer")
    found = candidates(terms, start)
    n = len(terms)
    out = {"terms_given": n, "start_index": start}
    if not found:
        out.update({"model": None, "next": [],
                    "note": f"no arithmetic, geometric, polynomial (degree up to {n - 2}) or linear recurrence (order up to {min(MAX_ORDER, (n - 1) // 2)}) fits with at least one term to spare; more terms might reveal one"})
        print(json.dumps(out))
        return
    order = {"constant": 0, "arithmetic": 0, "geometric": 1, "polynomial": 2, "linear_recurrence": 3}
    found.sort(key=lambda m: (m["determined_by"], order[m["model"]]))
    best = found[0]
    nexts = extend(best, terms, start, count)
    checks = n - best["determined_by"]
    public = lambda m: {k: v for k, v in m.items() if not k.startswith("_")}
    out.update(public(best))
    out["checked_on"] = checks
    agree = f"{checks} more term agrees" if checks == 1 else f"the other {checks} agree"
    out["fits"] = (f"fits all {n} given terms: the first {best['determined_by']} determine it and {agree}; "
                   "it is the simplest such model, not a proof that the sequence continues this way")
    out["next"] = [fmt(v) for v in nexts]
    others = []
    for m in found[1:]:
        other_next = extend(m, terms, start, count)
        entry = public(m)
        entry["checked_on"] = n - m["determined_by"]
        entry["agrees_on_next"] = other_next == nexts
        if not entry["agrees_on_next"]:
            entry["next"] = [fmt(v) for v in other_next]
        others.append(entry)
    out["also_fits"] = others
    print(json.dumps(out))


if __name__ == "__main__":
    try:
        main()
    except (ArithmeticError, ValueError, RecursionError) as error:
        fail(f"the numbers are out of the range this tool can compute: {type(error).__name__}")

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/sequence_tools/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/sequence_tools/1.0.0/sequence_tools.py