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 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