Files
AgentLab/backend/app/analysis/numbers.py

144 lines
5.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""分析结论里的数:精确的有理数进、规范的十进制字符串出(细案 01 §1「数用十进制」)。
结论要能逐字节复算(证据面板、导出、可验证报告的复算和封存的逐字段比,评测作答前后各算一次逐字比),所以:
- **算**:快照里的数一律换成 Fraction(浮点按最短写法 repr,数字文本按字面,DECIMAL 落成的文本不经浮点),加减乘除全程
精确——各段之和逐位等于总变化,Σ结构效应 + Σ自身效应逐位等于比率的变化。只有要开方、取对数、查分布的统计量是浮点。
- **写**:canon 把数写成规范的十进制字符串:不带指数、不带正号、去掉多余的 0、没有「-0」。能精确写成有限小数的(快照里的
数和它们的和差,0.25 这样的商)照写;除不尽的和浮点按 SIG 位有效数字四舍五入(ROUND_HALF_UP)。浮点只留 12 位有效
数字:不同机器的数学库在最后一两位上可能不一样,留 12 位同一份输入在哪台机器上复算都是同一串字。
"""
from __future__ import annotations
import math
from decimal import Decimal, InvalidOperation
from fractions import Fraction
from typing import Any
#: 除不尽的数、浮点保留几位有效数字
SIG = 12
#: 能精确写成有限小数、但有效数字超过这么多位的,也按 SIG 位写(只会出现在反复除以 2 这类极端情形)
_EXACT_MAX_DIGITS = 30
def to_fraction(value: Any) -> Fraction | None:
"""一个数换成精确的 Fraction:整数、Decimal、Fraction 原样;浮点按最短写法(repr);数字文本按字面。布尔、非数、无穷、
认不出的为 None。"""
if isinstance(value, bool) or value is None:
return None
if isinstance(value, Fraction):
return value
if isinstance(value, int):
return Fraction(value)
if isinstance(value, float):
if not math.isfinite(value):
return None
return Fraction(Decimal(repr(value)))
if isinstance(value, Decimal):
return Fraction(value) if value.is_finite() else None
if isinstance(value, str):
try:
d = Decimal(value.strip())
except (InvalidOperation, ValueError):
return None
return Fraction(d) if d.is_finite() else None
return None
def _terminating(q: Fraction) -> int | None:
"""q 能精确写成有限小数时要几位小数;不能为 None。"""
d = q.denominator
twos = fives = 0
while d % 2 == 0:
d //= 2
twos += 1
while d % 5 == 0:
d //= 5
fives += 1
return max(twos, fives) if d == 1 else None
def _plain(digits: int, exp10: int) -> str:
"""digits × 10^exp10(digits ≥ 0)写成不带指数、去尾零的十进制。"""
text = str(digits)
if exp10 >= 0:
return text + "0" * exp10 if digits else "0"
places = -exp10
text = text.rjust(places + 1, "0")
head, tail = text[:-places], text[-places:].rstrip("0")
return f"{head}.{tail}" if tail else head
def _decade(a: Fraction) -> int:
"""a > 0 的十进制量级:10^e ≤ a < 10^(e+1) 的 e。"""
try:
e = math.floor(math.log10(a.numerator) - math.log10(a.denominator))
except (ValueError, OverflowError):
e = len(str(a.numerator)) - len(str(a.denominator))
while Fraction(10) ** e > a:
e -= 1
while Fraction(10) ** (e + 1) <= a:
e += 1
return e
def round_sig(q: Fraction, sig: int = SIG) -> Fraction:
"""按 sig 位有效数字四舍五入(ROUND_HALF_UP)。"""
if q == 0:
return Fraction(0)
sign = -1 if q < 0 else 1
a = abs(q)
e = _decade(a)
scale = Fraction(10) ** (sig - 1 - e)
m = math.floor(a * scale + Fraction(1, 2))
return sign * Fraction(m) / scale
def round_places(q: Fraction, places: int) -> Fraction:
"""按 places 位小数四舍五入(ROUND_HALF_UP,负数按绝对值舍入,同 Decimal 的 ROUND_HALF_UP)。"""
sign = -1 if q < 0 else 1
scale = Fraction(10) ** places
return sign * Fraction(math.floor(abs(q) * scale + Fraction(1, 2))) / scale
def _write(q: Fraction) -> str:
"""有限小数的 q 写出来(调用方保证 q 能精确写成有限小数)。"""
places = _terminating(q) or 0
sign = "-" if q < 0 else ""
digits = abs(q) * (10 ** places)
assert digits.denominator == 1
return sign + _plain(int(digits), -places)
def canon(value: Any) -> str | None:
"""规范的十进制字符串(见文件头)。None、非数、无穷为 None。"""
if isinstance(value, float) and math.isfinite(value):
q = round_sig(to_fraction(value) or Fraction(0))
else:
q = to_fraction(value)
if q is None:
return None
places = _terminating(q)
if places is None or len(str(int(abs(q) * 10 ** places)).rstrip("0")) > _EXACT_MAX_DIGITS:
q = round_sig(q)
if q == 0:
return "0"
return _write(q)
def fixed(q: Fraction, places: int, *, strip: bool = True, group: bool = False) -> str:
"""按 places 位小数四舍五入写出来;strip 去掉尾零,group 加千分位。没有正号;舍成 0 的写「0」(不写「-0」)。"""
r = round_places(q, places)
sign = "-" if r < 0 else ""
scaled = abs(r) * (10 ** places)
whole, frac = divmod(int(scaled), 10 ** places)
head = f"{whole:,}" if group else str(whole)
tail = str(frac).rjust(places, "0") if places > 0 else ""
if strip:
tail = tail.rstrip("0")
text = f"{head}.{tail}" if tail else head
return sign + text
__all__ = ["SIG", "canon", "fixed", "round_places", "round_sig", "to_fraction"]