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/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
+ ]