ph-clm 0.9.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.
- ph_clm/__init__.py +27 -0
- ph_clm/budget.py +120 -0
- ph_clm/bundle.yaml +14 -0
- ph_clm/edits.py +722 -0
- ph_clm/keys.py +23 -0
- ph_clm/kinds.py +37 -0
- ph_clm/mirror.py +672 -0
- ph_clm/py.typed +0 -0
- ph_clm/sections.py +386 -0
- ph_clm/tools.py +585 -0
- ph_clm-0.9.0.dist-info/METADATA +106 -0
- ph_clm-0.9.0.dist-info/RECORD +15 -0
- ph_clm-0.9.0.dist-info/WHEEL +4 -0
- ph_clm-0.9.0.dist-info/entry_points.txt +6 -0
- ph_clm-0.9.0.dist-info/licenses/LICENSE +21 -0
ph_clm/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""pH's CLM bundle: the model manages its own context.
|
|
2
|
+
|
|
3
|
+
After *Context Language Models* (Shao et al., 2026): a model keeps the context it
|
|
4
|
+
works best with when it may edit that context itself. Here an edit is never a
|
|
5
|
+
rewrite of the log. Every one is a surface `replace` ph-core already folds — a
|
|
6
|
+
**tombstone** or a **replace** stands in for a run of whole sections, a
|
|
7
|
+
**rewrite** changes one result or reply in place — so the model's view changes,
|
|
8
|
+
the log keeps every original, and `transcript()` still shows the person what
|
|
9
|
+
happened. `context_diff` and `context_recall` read the originals back, and
|
|
10
|
+
`context_restore` undoes a section's latest edit.
|
|
11
|
+
|
|
12
|
+
Ideas from the paper's harness and from `pi-clm`; no code from either — the CLM
|
|
13
|
+
repository is CC BY-NC, and this package is MIT.
|
|
14
|
+
|
|
15
|
+
@module ph_clm
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import kinds as kinds
|
|
23
|
+
|
|
24
|
+
BUNDLE = Path(__file__).parent / "bundle.yaml"
|
|
25
|
+
"""The rows the `clm` bundle layers over a profile."""
|
|
26
|
+
|
|
27
|
+
__all__ = ["BUNDLE", "kinds"]
|
ph_clm/budget.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Context readouts: a line on a tool result when the context crosses a share of its
|
|
2
|
+
window, so the model knows to edit before the provider's limit, or compaction, decides
|
|
3
|
+
for it.
|
|
4
|
+
|
|
5
|
+
**On the result, not beside it.** A readout rides on the tool result whose output
|
|
6
|
+
crossed the threshold, appended in `tools/post-execute` as the context file's receipt
|
|
7
|
+
is (pi-clm's `sizeTrailer` does the same). Everything the model is shown is logged
|
|
8
|
+
(I3), and a notice message per threshold would be one more surface node to edit away.
|
|
9
|
+
|
|
10
|
+
**Measured as compaction measures** (`TokenMeter.baseline`): the provider's count of
|
|
11
|
+
the last request — system prompt and tool schemas included — moved by what the
|
|
12
|
+
surface did since, plus the result about to be logged, against the window that request
|
|
13
|
+
was routed with. A receipt's context figures are the same number. No window, no
|
|
14
|
+
readouts: there is no share to report.
|
|
15
|
+
|
|
16
|
+
**The last reminder comes a margin below compaction's trigger**, read from the engine
|
|
17
|
+
(`CompactionSeam.trigger_fraction`), so the model always has room to act before a
|
|
18
|
+
summary is written for it. A fixed last share did not: on a 16k slot, 75% left one
|
|
19
|
+
file read before compaction's 85%, and a live run spent it on a malformed call and
|
|
20
|
+
lost the sections it meant to edit to the summary (`Shares.of`).
|
|
21
|
+
|
|
22
|
+
**Once per crossing, and again after an edit brings the context back down.** Per
|
|
23
|
+
session, `Readouts` keeps how many thresholds the context had reached; a readout fires
|
|
24
|
+
when a result raises that, and each reading sets it, so an edit that frees space lowers
|
|
25
|
+
it and the next crossing is reported again. In memory: after a restart the first
|
|
26
|
+
reading sets it without a readout, so a resumed session is not told again what it was
|
|
27
|
+
told before the restart.
|
|
28
|
+
|
|
29
|
+
@module ph_clm.budget
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from collections.abc import Callable, Sequence
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
|
|
37
|
+
from ph.text import thousands
|
|
38
|
+
|
|
39
|
+
__all__ = ["Readouts", "Shares"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class Shares:
|
|
44
|
+
"""The thresholds one reading is judged against, ascending, as shares of its
|
|
45
|
+
window — and the share at which compaction summarizes on its own, when an engine
|
|
46
|
+
says."""
|
|
47
|
+
|
|
48
|
+
thresholds: tuple[float, ...]
|
|
49
|
+
window: int
|
|
50
|
+
compacts_at: float | None
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def of(
|
|
54
|
+
cls, remind_at: Sequence[float], margin: float, compacts_at: float | None, window: int
|
|
55
|
+
) -> Shares:
|
|
56
|
+
"""`remind_at`, with the last reminder moved to `margin` below compaction's
|
|
57
|
+
trigger: any configured share at or past that point gives way to it. Unchanged
|
|
58
|
+
with no engine, or a margin that would put it at or below zero; empty stays
|
|
59
|
+
empty, which is readouts off."""
|
|
60
|
+
shares = sorted(remind_at)
|
|
61
|
+
# Rounded, so 0.85 - 0.2 is the 0.65 it means rather than 0.6499999999999999.
|
|
62
|
+
last = None if compacts_at is None else round(compacts_at - margin, 6)
|
|
63
|
+
if shares and last is not None and last > 0:
|
|
64
|
+
shares = [*(share for share in shares if share < last), last]
|
|
65
|
+
return cls(tuple(shares), window, compacts_at)
|
|
66
|
+
|
|
67
|
+
def reached(self, tokens: int) -> int:
|
|
68
|
+
"""How many thresholds `tokens` is at or past."""
|
|
69
|
+
return sum(1 for share in self.thresholds if tokens >= share * self.window)
|
|
70
|
+
|
|
71
|
+
def line(self, tokens: int, how: str) -> str:
|
|
72
|
+
"""What a result carries when the context reached `tokens`: its size, what to
|
|
73
|
+
do, and when the next reminder comes — or that this is the last, and what
|
|
74
|
+
happens after it."""
|
|
75
|
+
level = self.reached(tokens)
|
|
76
|
+
share = tokens * 100 // self.window
|
|
77
|
+
head = f"[context: ~{thousands(tokens)} of {thousands(self.window)} tokens ({share}%)."
|
|
78
|
+
if level >= len(self.thresholds):
|
|
79
|
+
after = (
|
|
80
|
+
""
|
|
81
|
+
if self.compacts_at is None
|
|
82
|
+
else f" At {round(self.compacts_at * 100)}% older context is summarized for you."
|
|
83
|
+
)
|
|
84
|
+
return f"{head} This is the last reminder: free space now — {how}.{after}]"
|
|
85
|
+
following = round(self.thresholds[level] * 100)
|
|
86
|
+
return (
|
|
87
|
+
f"{head} Free space when it suits you — {how}. "
|
|
88
|
+
f"The next reminder comes at {following}%.]"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(slots=True)
|
|
93
|
+
class Readouts:
|
|
94
|
+
"""Which thresholds each session's context has reached, and when that rises."""
|
|
95
|
+
|
|
96
|
+
_reached: dict[str, int] = field(default_factory=dict)
|
|
97
|
+
|
|
98
|
+
def crossed(
|
|
99
|
+
self, session_id: str, shares: Shares, before: int, most: int, measure: Callable[[], int]
|
|
100
|
+
) -> int | None:
|
|
101
|
+
"""When a result takes the context from `before` tokens to at most `most`: the
|
|
102
|
+
tokens after, if that reached a threshold this session had not; `None`
|
|
103
|
+
otherwise.
|
|
104
|
+
|
|
105
|
+
`measure` counts the tokens after exactly, and is asked only when the answer
|
|
106
|
+
needs it — when `most` could reach a threshold `before` had not, or when a
|
|
107
|
+
readout fires. Tokenizing a long result is the costly part, and most results
|
|
108
|
+
cross nothing.
|
|
109
|
+
"""
|
|
110
|
+
floor = shares.reached(before)
|
|
111
|
+
known = self._reached.get(session_id, floor)
|
|
112
|
+
after = measure() if shares.reached(most) > floor else None
|
|
113
|
+
level = floor if after is None else shares.reached(after)
|
|
114
|
+
self._reached[session_id] = level
|
|
115
|
+
if level <= known:
|
|
116
|
+
return None
|
|
117
|
+
return measure() if after is None else after
|
|
118
|
+
|
|
119
|
+
def forget(self, session_id: str) -> None:
|
|
120
|
+
self._reached.pop(session_id, None)
|
ph_clm/bundle.yaml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# ph-clm: the model edits its own context, over any profile.
|
|
2
|
+
#
|
|
3
|
+
# Two rows: the section map with its tools, and the context file. Every edit is a
|
|
4
|
+
# surface `replace`, so compaction keeps working beside it — it summarizes the
|
|
5
|
+
# surface, revisions included — and stays the backstop when a session outgrows
|
|
6
|
+
# its window anyway.
|
|
7
|
+
|
|
8
|
+
- id: clm-context
|
|
9
|
+
name: clm-context
|
|
10
|
+
|
|
11
|
+
# The context as a file the model edits with its ordinary tools, read back after
|
|
12
|
+
# each call and landed through the same editor the tools use (`CLM`).
|
|
13
|
+
- id: clm-mirror
|
|
14
|
+
name: clm-mirror
|