aether-context 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aether_context/__init__.py +29 -0
- aether_context/_log.py +33 -0
- aether_context/cli.py +1191 -0
- aether_context/config.py +206 -0
- aether_context/context_pool.py +819 -0
- aether_context/encoder.py +213 -0
- aether_context/errors.py +93 -0
- aether_context/local_llm.py +846 -0
- aether_context/mpo.py +151 -0
- aether_context/py.typed +0 -0
- aether_context/quantize.py +86 -0
- aether_context/session.py +829 -0
- aether_context/slice_loader.py +501 -0
- aether_context/tokenizer.py +64 -0
- aether_context/ui.py +253 -0
- aether_context/witness.py +356 -0
- aether_context-0.3.0.dist-info/METADATA +429 -0
- aether_context-0.3.0.dist-info/RECORD +23 -0
- aether_context-0.3.0.dist-info/WHEEL +5 -0
- aether_context-0.3.0.dist-info/entry_points.txt +2 -0
- aether_context-0.3.0.dist-info/licenses/LICENSE +201 -0
- aether_context-0.3.0.dist-info/licenses/NOTICE.md +19 -0
- aether_context-0.3.0.dist-info/top_level.txt +1 -0
aether_context/ui.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# aether-context (Unlimited Context)
|
|
2
|
+
# Copyright (c) 2026 Aether AI
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Terminal presentation seam for the ``aether-context`` console script.
|
|
5
|
+
|
|
6
|
+
Stdlib only — the core dependency contract is numpy and nothing else, so there is no
|
|
7
|
+
``rich``/``colorama`` here. This module owns *how* CLI output looks; the commands in
|
|
8
|
+
:mod:`aether_context.cli` own *what* it says.
|
|
9
|
+
|
|
10
|
+
Three degradations, all decided once at import of the writer and never at each call site:
|
|
11
|
+
|
|
12
|
+
``color``
|
|
13
|
+
ANSI is emitted only when the stream is a real tty and the environment does not veto it
|
|
14
|
+
(``NO_COLOR`` wins over everything, ``FORCE_COLOR`` turns it back on, ``TERM=dumb``
|
|
15
|
+
disables). On Windows the VT100 mode is switched on explicitly; if that call fails the
|
|
16
|
+
styling silently falls back to plain text rather than printing escape soup.
|
|
17
|
+
|
|
18
|
+
``unicode``
|
|
19
|
+
Box-drawing and glyphs are only used when the stream's encoding can actually represent
|
|
20
|
+
them. A ``cp1252`` console (still the Windows default for a redirected pipe) gets the
|
|
21
|
+
ASCII table instead of a ``UnicodeEncodeError``.
|
|
22
|
+
|
|
23
|
+
``width``
|
|
24
|
+
Rules and padding follow ``COLUMNS``/the real terminal size, clamped so output stays
|
|
25
|
+
readable in a 40-column pane and does not sprawl on an ultrawide one.
|
|
26
|
+
|
|
27
|
+
Because color is off whenever stdout is not a tty, every command's output is plain text under
|
|
28
|
+
pytest, in a pipe, and in CI — so tests assert on words, never on escape sequences.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import shutil
|
|
34
|
+
import sys
|
|
35
|
+
from typing import IO, Final
|
|
36
|
+
|
|
37
|
+
#: Rules and headers never render narrower/wider than this, whatever the terminal reports.
|
|
38
|
+
_MIN_WIDTH: Final = 40
|
|
39
|
+
_MAX_WIDTH: Final = 78
|
|
40
|
+
|
|
41
|
+
# --- ANSI SGR codes ---------------------------------------------------------------------
|
|
42
|
+
_RESET: Final = "\033[0m"
|
|
43
|
+
_CODES: Final[dict[str, str]] = {
|
|
44
|
+
"bold": "\033[1m",
|
|
45
|
+
"dim": "\033[2m",
|
|
46
|
+
"cyan": "\033[36m",
|
|
47
|
+
"green": "\033[32m",
|
|
48
|
+
"yellow": "\033[33m",
|
|
49
|
+
"red": "\033[31m",
|
|
50
|
+
"blue": "\033[34m",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#: Status markers. The bracket text is load-bearing: `doctor` has always reported `[ok]` /
|
|
54
|
+
#: `[fail]` / `[skip]`, scripts grep for it, and the suite asserts on it — so color and glyphs
|
|
55
|
+
#: decorate that text, they never replace it.
|
|
56
|
+
_MARKS: Final[dict[str, tuple[str, str, str]]] = {
|
|
57
|
+
# key: (unicode glyph, ascii glyph, color)
|
|
58
|
+
"ok": ("✓", "+", "green"),
|
|
59
|
+
"warn": ("△", "!", "yellow"),
|
|
60
|
+
"fail": ("✗", "x", "red"),
|
|
61
|
+
"skip": ("·", "-", "dim"),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Console:
|
|
66
|
+
"""A styled writer bound to one stream, with its capabilities resolved once.
|
|
67
|
+
|
|
68
|
+
Construct with the stream you intend to write to (``sys.stdout`` for reports,
|
|
69
|
+
``sys.stderr`` for errors) so the capability probe matches the destination: piping stdout
|
|
70
|
+
to a file must not strip color from an error still going to the terminal.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, stream: IO[str] | None = None) -> None:
|
|
74
|
+
self.stream: IO[str] = stream if stream is not None else sys.stdout
|
|
75
|
+
self.color: bool = _supports_color(self.stream)
|
|
76
|
+
self.unicode: bool = _supports_unicode(self.stream)
|
|
77
|
+
self.width: int = _terminal_width()
|
|
78
|
+
|
|
79
|
+
# --- primitives ---------------------------------------------------------------------
|
|
80
|
+
def style(self, text: str, *names: str) -> str:
|
|
81
|
+
"""Wrap ``text`` in the named SGR styles, or return it unchanged when color is off."""
|
|
82
|
+
if not self.color or not names:
|
|
83
|
+
return text
|
|
84
|
+
codes = "".join(_CODES[n] for n in names if n in _CODES)
|
|
85
|
+
return f"{codes}{text}{_RESET}" if codes else text
|
|
86
|
+
|
|
87
|
+
def line(self, text: str = "") -> None:
|
|
88
|
+
"""Write one line to the bound stream, folded to ASCII when the stream can't encode.
|
|
89
|
+
|
|
90
|
+
Every command's output funnels through here, so the fold covers prose too — not just
|
|
91
|
+
the box-drawing set. Without it a ``cp1252`` pipe turns each em dash in a sentence into
|
|
92
|
+
a ``?``, which looks like a bug in the tool rather than a limitation of the console.
|
|
93
|
+
"""
|
|
94
|
+
print(_ascii_fold(text) if not self.unicode else text, file=self.stream)
|
|
95
|
+
|
|
96
|
+
def glyph(self, kind: str) -> str:
|
|
97
|
+
"""The bare status glyph for ``kind`` (``ok``/``warn``/``fail``/``skip``)."""
|
|
98
|
+
uni, ascii_, color = _MARKS[kind]
|
|
99
|
+
return self.style(uni if self.unicode else ascii_, color)
|
|
100
|
+
|
|
101
|
+
# --- composed output ----------------------------------------------------------------
|
|
102
|
+
def banner(self, title: str, subtitle: str = "") -> None:
|
|
103
|
+
"""The product wordmark: a boxed title with an optional subtitle beneath it."""
|
|
104
|
+
inner = min(self.width, _MAX_WIDTH) - 2
|
|
105
|
+
if self.unicode:
|
|
106
|
+
top, bottom, side = "╭" + "─" * inner + "╮", \
|
|
107
|
+
"╰" + "─" * inner + "╯", "│"
|
|
108
|
+
else:
|
|
109
|
+
top = bottom = "+" + "-" * inner + "+"
|
|
110
|
+
side = "|"
|
|
111
|
+
self.line(self.style(top, "cyan"))
|
|
112
|
+
self.line(
|
|
113
|
+
self.style(side, "cyan")
|
|
114
|
+
+ self.style(title.center(inner), "bold", "cyan")
|
|
115
|
+
+ self.style(side, "cyan")
|
|
116
|
+
)
|
|
117
|
+
if subtitle:
|
|
118
|
+
self.line(
|
|
119
|
+
self.style(side, "cyan")
|
|
120
|
+
+ self.style(subtitle.center(inner), "dim")
|
|
121
|
+
+ self.style(side, "cyan")
|
|
122
|
+
)
|
|
123
|
+
self.line(self.style(bottom, "cyan"))
|
|
124
|
+
|
|
125
|
+
def heading(self, text: str) -> None:
|
|
126
|
+
"""A section header: a blank line, then the title, then a rule the same width."""
|
|
127
|
+
self.line()
|
|
128
|
+
self.line(self.style(text, "bold"))
|
|
129
|
+
char = "─" if self.unicode else "-"
|
|
130
|
+
self.line(self.style(char * min(len(text), self.width), "dim"))
|
|
131
|
+
|
|
132
|
+
def field(self, label: str, value: str, *, pad: int = 12) -> None:
|
|
133
|
+
"""An indented ``label value`` row, labels left-aligned to a common column."""
|
|
134
|
+
self.line(f" {self.style(label.ljust(pad), 'dim')} {value}")
|
|
135
|
+
|
|
136
|
+
def check(self, kind: str, text: str, fix: str = "") -> None:
|
|
137
|
+
"""One diagnostic row — ``glyph [kind] text`` — plus an optional indented fix line.
|
|
138
|
+
|
|
139
|
+
``kind`` is one of ``ok``/``warn``/``fail``/``skip`` and is printed literally inside
|
|
140
|
+
the brackets, which is the contract the doctor's output has always had.
|
|
141
|
+
"""
|
|
142
|
+
self.line(f" {self.glyph(kind)} [{kind}] {text}")
|
|
143
|
+
if fix:
|
|
144
|
+
self.line(f" {self.style('fix:', 'dim')} {self.style(fix, 'bold')}")
|
|
145
|
+
|
|
146
|
+
def step(self, index: int, total: int, text: str) -> None:
|
|
147
|
+
"""A numbered wizard step header, e.g. ``[1/3] Pool size``."""
|
|
148
|
+
self.line()
|
|
149
|
+
self.line(f"{self.style(f'[{index}/{total}]', 'cyan')} {self.style(text, 'bold')}")
|
|
150
|
+
|
|
151
|
+
def note(self, text: str) -> None:
|
|
152
|
+
"""A dimmed aside — context the user can skim past."""
|
|
153
|
+
self.line(self.style(f" {text}", "dim"))
|
|
154
|
+
|
|
155
|
+
def command(self, text: str, comment: str = "") -> None:
|
|
156
|
+
"""A copy-pasteable command line, optionally trailed by a dimmed comment."""
|
|
157
|
+
tail = f" {self.style('# ' + comment, 'dim')}" if comment else ""
|
|
158
|
+
self.line(f" {self.style(text, 'bold', 'cyan')}{tail}")
|
|
159
|
+
|
|
160
|
+
def bar(self, fraction: float, slots: int = 24) -> str:
|
|
161
|
+
"""A fixed-width meter for ``fraction`` in [0, 1], as a string (never printed)."""
|
|
162
|
+
fraction = max(0.0, min(1.0, fraction))
|
|
163
|
+
filled = round(fraction * slots)
|
|
164
|
+
full, empty = ("█", "░") if self.unicode else ("#", ".")
|
|
165
|
+
return self.style(full * filled, "cyan") + self.style(empty * (slots - filled), "dim")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --- text folding ---------------------------------------------------------------------------
|
|
169
|
+
#: Typographic characters used in CLI prose, mapped to their ASCII equivalents.
|
|
170
|
+
_FOLD: Final[dict[int, str]] = str.maketrans({
|
|
171
|
+
"—": "-", "–": "-", "≈": "~", "→": "->", "·": "-", "…": "...",
|
|
172
|
+
"“": '"', "”": '"', "‘": "'", "’": "'", "×": "x",
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _ascii_fold(text: str) -> str:
|
|
177
|
+
"""Replace typographic characters with ASCII, dropping anything still unrepresentable.
|
|
178
|
+
|
|
179
|
+
The translation table covers what the CLI's own prose uses; the final pass is a backstop
|
|
180
|
+
for interpolated values (a path, a model name, an exception message) that could carry
|
|
181
|
+
anything at all.
|
|
182
|
+
"""
|
|
183
|
+
folded = text.translate(_FOLD)
|
|
184
|
+
return folded.encode("ascii", "replace").decode("ascii")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --- capability probes ----------------------------------------------------------------------
|
|
188
|
+
def _supports_color(stream: IO[str]) -> bool:
|
|
189
|
+
"""Decide whether to emit ANSI on ``stream``.
|
|
190
|
+
|
|
191
|
+
``NO_COLOR`` (any value, per no-color.org) disables unconditionally; ``FORCE_COLOR``
|
|
192
|
+
re-enables it even off a tty, which is how CI logs keep their color. Otherwise color needs
|
|
193
|
+
a real tty, a terminal that is not ``dumb``, and — on Windows — a console that accepts the
|
|
194
|
+
VT100 mode switch.
|
|
195
|
+
"""
|
|
196
|
+
if os.environ.get("NO_COLOR") is not None:
|
|
197
|
+
return False
|
|
198
|
+
if os.environ.get("FORCE_COLOR"):
|
|
199
|
+
return True
|
|
200
|
+
if os.environ.get("TERM") == "dumb":
|
|
201
|
+
return False
|
|
202
|
+
try:
|
|
203
|
+
if not stream.isatty():
|
|
204
|
+
return False
|
|
205
|
+
except (AttributeError, ValueError): # detached/closed stream
|
|
206
|
+
return False
|
|
207
|
+
if sys.platform == "win32":
|
|
208
|
+
return _enable_windows_vt()
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _enable_windows_vt() -> bool:
|
|
213
|
+
"""Turn on VT100 processing for the Windows console. False if it cannot be enabled.
|
|
214
|
+
|
|
215
|
+
Windows Terminal and conhost on Windows 10+ support ANSI once
|
|
216
|
+
``ENABLE_VIRTUAL_TERMINAL_PROCESSING`` is set on the output handle. Older consoles reject
|
|
217
|
+
the flag, and we would rather print clean text than raw escape bytes.
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
import ctypes
|
|
221
|
+
|
|
222
|
+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
223
|
+
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
|
|
224
|
+
mode = ctypes.c_uint32()
|
|
225
|
+
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
226
|
+
return False
|
|
227
|
+
return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004))
|
|
228
|
+
except Exception: # noqa: BLE001 - any failure here just means "no color"
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _supports_unicode(stream: IO[str]) -> bool:
|
|
233
|
+
"""True when ``stream``'s encoding can represent the box-drawing/glyph set.
|
|
234
|
+
|
|
235
|
+
Probed by actually encoding the widest character we use. A Windows console still running
|
|
236
|
+
``cp1252`` fails here and gets the ASCII table, instead of raising ``UnicodeEncodeError``
|
|
237
|
+
in the middle of a report.
|
|
238
|
+
"""
|
|
239
|
+
encoding = getattr(stream, "encoding", None) or "ascii"
|
|
240
|
+
try:
|
|
241
|
+
"─╭✓█".encode(encoding)
|
|
242
|
+
except (UnicodeEncodeError, LookupError):
|
|
243
|
+
return False
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _terminal_width() -> int:
|
|
248
|
+
"""The usable output width, clamped to a readable range."""
|
|
249
|
+
try:
|
|
250
|
+
columns = shutil.get_terminal_size().columns
|
|
251
|
+
except (OSError, ValueError):
|
|
252
|
+
columns = _MAX_WIDTH
|
|
253
|
+
return max(_MIN_WIDTH, min(columns, _MAX_WIDTH))
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# aether-context (Unlimited Context)
|
|
2
|
+
# Copyright (c) 2026 Aether AI
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""+/- retention witness — page-replacement scoring + budget eviction.
|
|
5
|
+
|
|
6
|
+
This is the *fidelity field* that decides which encoded slices stay resident in the
|
|
7
|
+
context pool and which fade out. It is a **pure scoring function over access
|
|
8
|
+
events**, with no external tiering or promotion coupling.
|
|
9
|
+
|
|
10
|
+
Why score on salience, not recency
|
|
11
|
+
-----------------------------------
|
|
12
|
+
The naive cache evicts on recency/frequency (LRU/LFU). For a long coding run that is
|
|
13
|
+
exactly backwards: the load-bearing fact established an hour ago is rare and old, so an
|
|
14
|
+
LRU policy throws it out first. Two rules fix it:
|
|
15
|
+
|
|
16
|
+
1. **Score retention on SURPRISE x IMPACT x UNIQUENESS, not frequency.** The geometric
|
|
17
|
+
mean (see :func:`retention_score`) means one weak driver can't be masked by a strong
|
|
18
|
+
one — a slice has to be salient on every axis to score high. In coding terms:
|
|
19
|
+
surprise ~ content density, impact ~ query relevance, uniqueness ~ 1/(1+similar).
|
|
20
|
+
2. **A salient slice fades by *idle time*, never by raw frequency**, and **re-hardens**
|
|
21
|
+
the instant it is relevant again.
|
|
22
|
+
|
|
23
|
+
Temporal lock-in (anti-thrash)
|
|
24
|
+
------------------------------
|
|
25
|
+
A pure score-ranked governor can *thrash*: a slice paged in from disk on this turn, with
|
|
26
|
+
only a floor-level salience, becomes eligible for eviction on the very next turn — so the
|
|
27
|
+
attention loop evicts it, immediately cold-misses it back, and the window flaps. To break
|
|
28
|
+
that loop a freshly touched slice gets a short **temporal lock-in**: for ``pin_periods``
|
|
29
|
+
after its last touch it carries a :data:`DEFAULT_PIN_BONUS` *immunity bonus* on its
|
|
30
|
+
eviction score. The bonus is deliberately small — it lets a just-paged-in slice survive a
|
|
31
|
+
wave of *comparable-salience* churn, but it never lets a low slice outrank a genuinely
|
|
32
|
+
load-bearing one, so salience still wins where it matters. The bonus affects **eviction
|
|
33
|
+
ordering only** — :meth:`rank` / :meth:`score` (used for retrieval) are unchanged.
|
|
34
|
+
|
|
35
|
+
The lifecycle of a slice id
|
|
36
|
+
---------------------------
|
|
37
|
+
* :meth:`Witness.touch` — **harden** (or re-harden): register the slice and lift its
|
|
38
|
+
score toward the access salience. A re-touch never demotes a still-strong slice.
|
|
39
|
+
* :meth:`Witness.decay` — **fade**: recompute every slice's live score from how long it
|
|
40
|
+
has been idle. Monotone non-increasing in elapsed time, never negative.
|
|
41
|
+
* :meth:`Witness.rank` — order ids by current score, highest (most retained) first.
|
|
42
|
+
* :meth:`Witness.budget_evict` — drop the lowest-score slices first until the pool fits
|
|
43
|
+
under its byte ceiling, then stop. Returns the evicted ids.
|
|
44
|
+
|
|
45
|
+
Fail-soft: the witness is an *optimization* over the pool, never a correctness gate. It
|
|
46
|
+
only ever returns ids; the pool is the single source of truth for slice payloads.
|
|
47
|
+
"""
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import math
|
|
51
|
+
from dataclasses import dataclass
|
|
52
|
+
|
|
53
|
+
from aether_context._log import get_logger
|
|
54
|
+
|
|
55
|
+
_log = get_logger(__name__)
|
|
56
|
+
|
|
57
|
+
# --- retention math constants -----------------------------------------------
|
|
58
|
+
#: At or above this retention score a slice is considered salient ("hardened"): it is
|
|
59
|
+
#: the last thing the budget governor will evict.
|
|
60
|
+
SALIENT_THRESHOLD: float = 0.60
|
|
61
|
+
#: Exponential fade rate per unit of idle time. Tuned so a unit-salience slice retains
|
|
62
|
+
#: ~37% of its score after ~20 idle units (1 / DEFAULT_DECAY_RATE), i.e. a slow, steady
|
|
63
|
+
#: fade rather than a cliff — old-but-salient slices survive a long run.
|
|
64
|
+
DEFAULT_DECAY_RATE: float = 0.05
|
|
65
|
+
#: How many idle units a slice stays *temporally locked in* (immune-boosted) after a touch.
|
|
66
|
+
#: Short by design: just long enough that a freshly paged-in slice survives the immediate
|
|
67
|
+
#: next eviction pass rather than flapping straight back to disk.
|
|
68
|
+
DEFAULT_PIN_PERIODS: float = 3.0
|
|
69
|
+
#: Eviction-score bonus a locked-in (recently touched) slice carries. Small on purpose: it
|
|
70
|
+
#: lets a fresh slice beat *comparable-salience* churn, but cannot lift a low slice above a
|
|
71
|
+
#: genuinely load-bearing one — salience still wins where it counts.
|
|
72
|
+
DEFAULT_PIN_BONUS: float = 0.25
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _clamp_unit(x: float) -> float:
|
|
76
|
+
"""Clamp a float into the closed unit interval ``[0.0, 1.0]``."""
|
|
77
|
+
return max(0.0, min(1.0, float(x)))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def retention_score(surprise: float, impact: float, uniqueness: float) -> float:
|
|
81
|
+
"""Retention in ``[0,1]`` = geometric mean of the three drivers.
|
|
82
|
+
|
|
83
|
+
The geometric mean is deliberate: one weak driver can't be masked by a strong one
|
|
84
|
+
(unlike a sum), so a slice must be salient on every axis to score high. All inputs
|
|
85
|
+
are clamped to ``[0,1]`` first.
|
|
86
|
+
|
|
87
|
+
surprise content density / novelty of the slice, normalized to [0,1]
|
|
88
|
+
impact query relevance / magnitude of the slice, normalized to [0,1]
|
|
89
|
+
uniqueness ``1 / (1 + neighbors_in_embedding_space)`` -> rarer = higher
|
|
90
|
+
"""
|
|
91
|
+
s = _clamp_unit(surprise)
|
|
92
|
+
i = _clamp_unit(impact)
|
|
93
|
+
u = _clamp_unit(uniqueness)
|
|
94
|
+
return (s * i * u) ** (1.0 / 3.0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def squash(x: float, scale: float) -> float:
|
|
98
|
+
"""``tanh`` squash of a raw magnitude to ``[0,1]`` at a given scale.
|
|
99
|
+
|
|
100
|
+
Used to normalize an unbounded magnitude (e.g. a raw relevance distance) into a
|
|
101
|
+
driver for :func:`retention_score`. A non-positive ``scale`` is a safe no-op (0.0).
|
|
102
|
+
"""
|
|
103
|
+
if scale <= 0:
|
|
104
|
+
return 0.0
|
|
105
|
+
return math.tanh(abs(x) / scale)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def uniqueness_from_neighbors(neighbor_count: int) -> float:
|
|
109
|
+
"""``1 / (1 + max(0, neighbor_count))`` — rarer slices (fewer neighbors) score higher."""
|
|
110
|
+
return 1.0 / (1.0 + max(0, neighbor_count))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class _Entry:
|
|
115
|
+
"""Per-slice retention state: a base score anchored at its last touch time.
|
|
116
|
+
|
|
117
|
+
The *live* score is ``base * exp(-rate * (now - last_touch))`` — recomputed lazily
|
|
118
|
+
so a slice that has been idle longer has faded further. Only the base score and the
|
|
119
|
+
anchor time are stored; the decayed value is always derived.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
base: float # score at last_touch (in [0,1])
|
|
123
|
+
last_touch: float # the ``now`` at which ``base`` was set
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Witness:
|
|
127
|
+
"""The +/- fidelity field over slice ids: harden, fade, re-harden, rank, evict.
|
|
128
|
+
|
|
129
|
+
Pure bookkeeping over access events. Holds only ``{slice_id -> _Entry}``; never the
|
|
130
|
+
slice payloads (those live in the context pool). Stateless with respect to the pool:
|
|
131
|
+
callers feed it ids + saliences and read back rankings / eviction lists.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
decay_rate: float = DEFAULT_DECAY_RATE,
|
|
137
|
+
*,
|
|
138
|
+
pin_periods: float = DEFAULT_PIN_PERIODS,
|
|
139
|
+
pin_bonus: float = DEFAULT_PIN_BONUS,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Create an empty witness.
|
|
142
|
+
|
|
143
|
+
``decay_rate`` (> 0) sets how fast idle slices fade; the default gives a slow,
|
|
144
|
+
steady fade so old-but-salient slices survive a long run. A non-positive value
|
|
145
|
+
falls back to :data:`DEFAULT_DECAY_RATE`.
|
|
146
|
+
|
|
147
|
+
``pin_periods`` / ``pin_bonus`` configure the temporal lock-in (anti-thrash): a
|
|
148
|
+
slice touched within ``pin_periods`` of the eviction ``now`` carries ``pin_bonus``
|
|
149
|
+
on its eviction score. Pass ``pin_periods <= 0`` (or ``pin_bonus <= 0``) to disable
|
|
150
|
+
the lock-in entirely — eviction then ranks on pure salience.
|
|
151
|
+
"""
|
|
152
|
+
self._decay_rate: float = decay_rate if decay_rate > 0 else DEFAULT_DECAY_RATE
|
|
153
|
+
self._pin_periods: float = max(0.0, float(pin_periods))
|
|
154
|
+
self._pin_bonus: float = max(0.0, float(pin_bonus))
|
|
155
|
+
self._entries: dict[str, _Entry] = {}
|
|
156
|
+
# Permanently retained ids. Distinct from the temporal lock-in above,
|
|
157
|
+
# which only holds a *recently touched* slice back against comparable
|
|
158
|
+
# churn and expires. A permanent slice never fades and is never
|
|
159
|
+
# evicted, at any pressure, until it is explicitly released.
|
|
160
|
+
self._permanent: set[str] = set()
|
|
161
|
+
|
|
162
|
+
# -- harden / re-harden ----------------------------------------------------
|
|
163
|
+
def touch(self, slice_id: str, salience: float, now: float = 0.0) -> float:
|
|
164
|
+
"""**Harden** (or re-harden) ``slice_id`` toward ``salience`` at time ``now``.
|
|
165
|
+
|
|
166
|
+
On first touch the slice is registered with ``salience`` (clamped to ``[0,1]``).
|
|
167
|
+
On re-touch the new base is ``max(decayed_current_score, salience)`` so a strong
|
|
168
|
+
re-touch lifts a faded slice back up and a *weak* re-touch never demotes a still-
|
|
169
|
+
strong slice. The anchor time is reset to ``now`` either way (the slice is fresh).
|
|
170
|
+
|
|
171
|
+
Returns the slice's new (base) score.
|
|
172
|
+
"""
|
|
173
|
+
s = _clamp_unit(salience)
|
|
174
|
+
existing = self._entries.get(slice_id)
|
|
175
|
+
if existing is not None:
|
|
176
|
+
decayed = self._decayed_score(existing, now)
|
|
177
|
+
s = max(decayed, s)
|
|
178
|
+
self._entries[slice_id] = _Entry(base=s, last_touch=float(now))
|
|
179
|
+
return s
|
|
180
|
+
|
|
181
|
+
# -- permanence ------------------------------------------------------------
|
|
182
|
+
def pin(self, slice_id: str) -> None:
|
|
183
|
+
"""Retain ``slice_id`` permanently: never faded, never evicted.
|
|
184
|
+
|
|
185
|
+
For the load-bearing content an agent must not lose no matter how long
|
|
186
|
+
it runs -- its operating doctrine, its skills, its grounding. Those are
|
|
187
|
+
resident because of *what they are*, not because they happened to
|
|
188
|
+
embed near the current query, and salience ranking cannot express that:
|
|
189
|
+
a slice that is never queried decays exactly like one that is
|
|
190
|
+
irrelevant.
|
|
191
|
+
|
|
192
|
+
Pinning an id the witness has not seen is allowed and remembered, so
|
|
193
|
+
callers may pin before or after the first touch without ordering care.
|
|
194
|
+
"""
|
|
195
|
+
self._permanent.add(str(slice_id))
|
|
196
|
+
|
|
197
|
+
def unpin(self, slice_id: str) -> None:
|
|
198
|
+
"""Release ``slice_id`` back to ordinary fade and eviction."""
|
|
199
|
+
self._permanent.discard(str(slice_id))
|
|
200
|
+
|
|
201
|
+
def is_permanent(self, slice_id: str) -> bool:
|
|
202
|
+
"""True when ``slice_id`` is permanently retained."""
|
|
203
|
+
return str(slice_id) in self._permanent
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def permanent_ids(self) -> frozenset[str]:
|
|
207
|
+
"""The permanently retained ids (a snapshot)."""
|
|
208
|
+
return frozenset(self._permanent)
|
|
209
|
+
|
|
210
|
+
# -- fade ------------------------------------------------------------------
|
|
211
|
+
def decay(self, now: float) -> None:
|
|
212
|
+
"""**Fade** every slice: collapse each live (decayed) score into its base at ``now``.
|
|
213
|
+
|
|
214
|
+
After this call each slice's stored base equals its decayed value as of ``now`` and
|
|
215
|
+
its anchor is ``now``. The result is monotone non-increasing in elapsed time and
|
|
216
|
+
never negative. Calling repeatedly with non-decreasing ``now`` keeps fading.
|
|
217
|
+
"""
|
|
218
|
+
for slice_id, entry in self._entries.items():
|
|
219
|
+
if slice_id in self._permanent:
|
|
220
|
+
# Re-anchor without fading: a permanent slice is as fresh at
|
|
221
|
+
# hour ten as at minute one.
|
|
222
|
+
self._entries[slice_id] = _Entry(base=entry.base, last_touch=float(now))
|
|
223
|
+
continue
|
|
224
|
+
faded = self._decayed_score(entry, now)
|
|
225
|
+
self._entries[slice_id] = _Entry(base=faded, last_touch=float(now))
|
|
226
|
+
|
|
227
|
+
def _decayed_score(self, entry: _Entry, now: float) -> float:
|
|
228
|
+
"""Live score for an entry as of ``now``: ``base * exp(-rate * max(0, elapsed))``."""
|
|
229
|
+
elapsed = float(now) - entry.last_touch
|
|
230
|
+
if elapsed <= 0.0:
|
|
231
|
+
return entry.base # no negative-time lift; un-aged slices keep their base
|
|
232
|
+
return entry.base * math.exp(-self._decay_rate * elapsed)
|
|
233
|
+
|
|
234
|
+
# -- read ------------------------------------------------------------------
|
|
235
|
+
def score(self, slice_id: str, now: float | None = None) -> float:
|
|
236
|
+
"""Current retention score of ``slice_id`` (0.0 if unknown).
|
|
237
|
+
|
|
238
|
+
If ``now`` is given, returns the decayed-as-of-``now`` score without mutating
|
|
239
|
+
state; otherwise returns the stored base score (the value as of its last touch
|
|
240
|
+
or the last :meth:`decay`).
|
|
241
|
+
"""
|
|
242
|
+
entry = self._entries.get(slice_id)
|
|
243
|
+
if entry is None:
|
|
244
|
+
return 0.0
|
|
245
|
+
if now is None:
|
|
246
|
+
return entry.base
|
|
247
|
+
return self._decayed_score(entry, now)
|
|
248
|
+
|
|
249
|
+
def ids(self) -> list[str]:
|
|
250
|
+
"""All known slice ids, ordered highest-score first (alias of :meth:`rank`)."""
|
|
251
|
+
return self.rank()
|
|
252
|
+
|
|
253
|
+
def rank(self, now: float | None = None) -> list[str]:
|
|
254
|
+
"""Slice ids ordered by score, **highest (most retained) first**.
|
|
255
|
+
|
|
256
|
+
Ties break on insertion order (Python dict order) for determinism. If ``now`` is
|
|
257
|
+
given, ranks by the decayed-as-of-``now`` score without mutating state.
|
|
258
|
+
"""
|
|
259
|
+
scored = [(sid, self.score(sid, now=now)) for sid in self._entries]
|
|
260
|
+
# stable sort by descending score; ties keep dict insertion order
|
|
261
|
+
scored.sort(key=lambda pair: pair[1], reverse=True)
|
|
262
|
+
return [sid for sid, _ in scored]
|
|
263
|
+
|
|
264
|
+
def forget(self, slice_id: str) -> None:
|
|
265
|
+
"""Drop a slice id from the witness. A no-op if it is unknown (safe to call)."""
|
|
266
|
+
self._entries.pop(slice_id, None)
|
|
267
|
+
|
|
268
|
+
# -- temporal lock-in ------------------------------------------------------
|
|
269
|
+
def _is_pinned(self, entry: _Entry, now: float | None) -> bool:
|
|
270
|
+
"""Whether ``entry`` is temporally locked in (touched within ``pin_periods`` of ``now``)."""
|
|
271
|
+
if now is None or self._pin_periods <= 0.0 or self._pin_bonus <= 0.0:
|
|
272
|
+
return False
|
|
273
|
+
return (float(now) - entry.last_touch) < self._pin_periods
|
|
274
|
+
|
|
275
|
+
def _eviction_score(self, entry: _Entry, now: float | None) -> float:
|
|
276
|
+
"""Eviction-ranking score: base salience plus the lock-in bonus if pinned.
|
|
277
|
+
|
|
278
|
+
Distinct from :meth:`score`: this drives **eviction order only** and never affects
|
|
279
|
+
retrieval ranking. The bonus is *added* (not multiplied) so it lifts a fresh slice
|
|
280
|
+
above comparable churn without overriding a genuinely load-bearing salience.
|
|
281
|
+
"""
|
|
282
|
+
if self._is_pinned(entry, now):
|
|
283
|
+
return entry.base + self._pin_bonus
|
|
284
|
+
return entry.base
|
|
285
|
+
|
|
286
|
+
def eviction_order(self, now: float | None = None) -> list[str]:
|
|
287
|
+
"""Slice ids ordered **most-evictable first** (lowest eviction score first).
|
|
288
|
+
|
|
289
|
+
Ranks on :meth:`_eviction_score`, so when ``now`` is given a temporally locked-in
|
|
290
|
+
slice is held back behind comparable-salience churn (see the module docstring).
|
|
291
|
+
Ties break on insertion order for determinism. With ``now=None`` (or the lock-in
|
|
292
|
+
disabled) this is simply ascending salience.
|
|
293
|
+
"""
|
|
294
|
+
scored = [
|
|
295
|
+
(sid, self._eviction_score(e, now))
|
|
296
|
+
for sid, e in self._entries.items()
|
|
297
|
+
if sid not in self._permanent
|
|
298
|
+
]
|
|
299
|
+
# stable ascending sort; ties keep dict insertion order (most-evictable first)
|
|
300
|
+
scored.sort(key=lambda pair: pair[1])
|
|
301
|
+
# Permanent ids rank last unconditionally: they are not candidates at
|
|
302
|
+
# any score, so no amount of pressure can order them forward.
|
|
303
|
+
return [sid for sid, _ in scored] + [
|
|
304
|
+
sid for sid in self._entries if sid in self._permanent
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
# -- budget eviction -------------------------------------------------------
|
|
308
|
+
def budget_evict(
|
|
309
|
+
self, ceiling_bytes: int, bytes_per_slice: int, now: float | None = None
|
|
310
|
+
) -> list[str]:
|
|
311
|
+
"""Evict the **most-evictable** slices first until the pool fits under ``ceiling_bytes``.
|
|
312
|
+
|
|
313
|
+
Each retained slice costs ``bytes_per_slice``. Slices are dropped in
|
|
314
|
+
:meth:`eviction_order` (least-retained first, with the temporal lock-in applied
|
|
315
|
+
when ``now`` is given) and eviction **stops the instant** the remaining count fits,
|
|
316
|
+
so the pool ends exactly at or below the ceiling. The witness drops the evicted ids
|
|
317
|
+
from its own bookkeeping.
|
|
318
|
+
|
|
319
|
+
Returns the evicted ids in eviction order (most-evictable / lowest score first). A
|
|
320
|
+
no-op (``[]``) when the pool already fits or ``bytes_per_slice`` is non-positive.
|
|
321
|
+
"""
|
|
322
|
+
if bytes_per_slice <= 0:
|
|
323
|
+
return []
|
|
324
|
+
max_slices = max(0, ceiling_bytes // bytes_per_slice)
|
|
325
|
+
order = self.eviction_order(now=now) # most-evictable first
|
|
326
|
+
if len(order) <= max_slices:
|
|
327
|
+
return []
|
|
328
|
+
# drop the most-evictable prefix; keep the `max_slices` most-retained survivors
|
|
329
|
+
evicted = order[: len(order) - max_slices]
|
|
330
|
+
# A permanent slice is not evictable at any pressure. Without this the
|
|
331
|
+
# prefix would reach the permanent tail once the ceiling fell below the
|
|
332
|
+
# permanent count -- exactly the moment the guarantee matters most, and
|
|
333
|
+
# the pool would silently drop the agent's doctrine to make room for
|
|
334
|
+
# whatever it happened to be reading.
|
|
335
|
+
evicted = [sid for sid in evicted if sid not in self._permanent]
|
|
336
|
+
if not evicted:
|
|
337
|
+
return []
|
|
338
|
+
for sid in evicted:
|
|
339
|
+
del self._entries[sid]
|
|
340
|
+
_log.debug(
|
|
341
|
+
"budget_evict dropped %d slice(s) to fit %d bytes (%d per slice)",
|
|
342
|
+
len(evicted), ceiling_bytes, bytes_per_slice,
|
|
343
|
+
)
|
|
344
|
+
return evicted
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
__all__ = [
|
|
348
|
+
"Witness",
|
|
349
|
+
"retention_score",
|
|
350
|
+
"squash",
|
|
351
|
+
"uniqueness_from_neighbors",
|
|
352
|
+
"SALIENT_THRESHOLD",
|
|
353
|
+
"DEFAULT_DECAY_RATE",
|
|
354
|
+
"DEFAULT_PIN_PERIODS",
|
|
355
|
+
"DEFAULT_PIN_BONUS",
|
|
356
|
+
]
|