Files
firestar5683 3e9f070c70 Curve Speed
Resolve curve speed and smoothing through a dedicated learning and preference owner.

Co-authored-by: whoisdomi <dcantu83@gmail.com>
2026-10-14 20:02:39 -05:00

185 lines
7.1 KiB
Python

"""Pure learned comfort curve; the runtime owns permission to learn and persist."""
from bisect import bisect_left
from dataclasses import dataclass
import math
from typing import cast, TypeGuard
BUCKET_COUNT = 24
MIN_CURVATURE = 0.0005
MAX_CURVATURE = 0.02
GRID = tuple(MIN_CURVATURE * (MAX_CURVATURE / MIN_CURVATURE) ** (i / (BUCKET_COUNT - 1)) for i in range(BUCKET_COUNT))
KEYS = tuple(str(round(k, 6)) for k in GRID)
CURVATURES = tuple(float(key) for key in KEYS)
LOG_GRID = tuple(math.log(k) for k in GRID)
PRIOR_X = (0.001, 0.003, 0.01, 0.03, 0.1)
PRIOR_Y = (1.5, 1.8, 2.2, 2.6, 2.9)
MIN_COMFORT = 1.2
MAX_COMFORT = 3.2
HISTORY_WEIGHT = 600
PRIOR_WEIGHT = 100
CALIBRATION_SAMPLES = 200
NUDGE_WEIGHT = 20
MAX_COUNT = 2**53 - 1
MAX_SAVED_BUCKETS = 256
def finite_number(value: object) -> TypeGuard[int | float]:
if type(value) not in (int, float):
return False
try:
return math.isfinite(cast(int | float, value))
except OverflowError:
return False
def interpolate(x: float, xs: tuple[float, ...], ys: tuple[float, ...]) -> float:
index = bisect_left(xs, x)
if index == 0:
return ys[0]
if index == len(xs):
return ys[-1]
fraction = (x - xs[index - 1]) / (xs[index] - xs[index - 1])
return ys[index - 1] * (1 - fraction) + ys[index] * fraction
def bucket_index(curvature: float) -> int:
if not finite_number(curvature):
raise ValueError("curvature must be finite")
log_k = math.log(min(max(abs(curvature), MIN_CURVATURE), MAX_CURVATURE))
return min(range(BUCKET_COUNT), key=lambda i: abs(LOG_GRID[i] - log_k))
def monotone_fit(values: list[float], weights: list[int]) -> tuple[float, ...]:
"""Weighted pool-adjacent-violators fit, increasing with curvature."""
if len(values) != len(weights) or any(not finite_number(v) for v in values) or any(type(w) is not int or w <= 0 for w in weights):
raise ValueError("fit requires finite values and positive integer weights")
blocks: list[tuple[float, int, int]] = []
for value, weight in zip(values, weights, strict=True):
blocks.append((value, weight, 1))
while len(blocks) >= 2 and blocks[-2][0] > blocks[-1][0]:
right, left = blocks.pop(), blocks.pop()
total = left[1] + right[1]
mean = left[0] * (left[1] / total) + right[0] * (right[1] / total)
blocks.append((mean, total, left[2] + right[2]))
return tuple(mean for mean, _weight, size in blocks for _ in range(size))
@dataclass(frozen=True)
class Bucket:
average: float
count: int
@dataclass(frozen=True)
class LoadResult:
curve: 'LearnedCurve'
valid: bool
reason: str
migrated: bool = False
class LearnedCurve:
"""No Params or vehicle access. A successful write acknowledges a revision."""
def __init__(self):
self._buckets: dict[int, Bucket] = {}
self.revision = 0
self._saved_revision = 0
self._fit = tuple(interpolate(k, PRIOR_X, PRIOR_Y) for k in CURVATURES)
@classmethod
def load(cls, document: object) -> LoadResult:
curve = cls()
if document is None:
return LoadResult(curve, True, "empty")
if type(document) is not dict:
return LoadResult(curve, False, "invalid_document")
data = cast(dict[object, object], document)
legacy = "version" not in data
if not legacy:
if set(data) != {"version", "buckets"} or type(data["version"]) is not int or data["version"] != 1:
return LoadResult(curve, False, "unsupported_document")
document = data["buckets"]
if type(document) is not dict or len(document) > MAX_SAVED_BUCKETS:
return LoadResult(curve, False, "invalid_buckets")
merged: dict[int, Bucket] = {}
try:
for key, value in cast(dict[object, object], document).items():
if not isinstance(key, str) or len(key) > 64 or type(value) is not dict or set(value) != {"average", "count"}:
raise ValueError("invalid bucket")
curvature = float(key)
sample = cast(dict[str, object], value)
average, count = sample["average"], sample["count"]
if not finite_number(average) or average < 0 or type(count) is not int or not 0 < count <= MAX_COUNT:
raise ValueError("invalid sample")
index = bucket_index(curvature)
old = merged.get(index, Bucket(0.0, 0))
total = old.count + count
if total > MAX_COUNT:
raise ValueError("sample count overflow")
merged[index] = Bucket(old.average * (old.count / total) + average * (count / total), total)
except (ValueError, TypeError, OverflowError):
# Never partially normalize a corrupt document and later overwrite it.
return LoadResult(curve, False, "invalid_sample")
curve._buckets = merged
curve._rebuild()
return LoadResult(curve, True, "legacy" if legacy else "loaded", migrated=legacy)
@property
def dirty(self) -> bool:
return self.revision != self._saved_revision
def acknowledge_saved(self, revision: int) -> None:
if type(revision) is not int or not self._saved_revision <= revision <= self.revision:
raise ValueError("invalid saved revision")
self._saved_revision = revision
def document(self) -> dict:
return {"version": 1, "buckets": {KEYS[i]: {"average": item.average, "count": item.count}
for i, item in sorted(self._buckets.items())}}
@property
def progress(self) -> float:
return 100 * sum(min(item.count / CALIBRATION_SAMPLES, 1.0) for item in self._buckets.values()) / BUCKET_COUNT
@property
def average_comfort(self) -> float:
count = sum(item.count for item in self._buckets.values())
return sum(self._fit[i] * (item.count / count) for i, item in self._buckets.items()) if count else 2.0
def comfort(self, curvature: float) -> float:
if not finite_number(curvature):
raise ValueError("curvature must be finite")
return interpolate(abs(curvature), CURVATURES, self._fit)
def observe(self, curvature: float, lateral_acceleration: float) -> None:
self._record(curvature, lateral_acceleration, 1)
def nudge(self, curvature: float, lateral_acceleration: float) -> None:
if not finite_number(lateral_acceleration):
raise ValueError("sample must be finite")
self._record(curvature, min(max(lateral_acceleration, MIN_COMFORT), MAX_COMFORT), NUDGE_WEIGHT)
def _record(self, curvature: float, sample: float, weight: int) -> None:
index = bucket_index(curvature)
if not finite_number(sample) or sample < 0:
raise ValueError("sample must be finite and nonnegative")
old = self._buckets.get(index, Bucket(sample, 0))
history = min(old.count, HISTORY_WEIGHT)
total = history + weight
self._buckets[index] = Bucket(old.average * (history / total) + sample * (weight / total), min(old.count + weight, MAX_COUNT))
self.revision += 1
self._rebuild()
def _rebuild(self) -> None:
values, weights = [], []
for i, curvature in enumerate(CURVATURES):
prior = interpolate(curvature, PRIOR_X, PRIOR_Y)
sample = self._buckets.get(i, Bucket(prior, 0))
confidence = sample.count / (sample.count + PRIOR_WEIGHT)
values.append(min(max(confidence * sample.average + (1 - confidence) * prior, MIN_COMFORT), MAX_COMFORT))
weights.append(sample.count + PRIOR_WEIGHT)
self._fit = monotone_fit(values, weights)