jul 0.1.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.
jul/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """jul — Juste Un LLM: local typed decisions, with the interface of the TypeSafe (Jev) Python SDK.
2
+
3
+ Swap the import and existing code keeps working:
4
+
5
+ # from typesafe_sdk import TypeSafeClient, Choice, Noul, Score
6
+ from jul import TypeSafeClient, Choice, Noul, Score
7
+
8
+ Two extras Jev does not have: a `Context` describing the data to sort, and `client.autotune(...)`, a small
9
+ head trained in seconds on labeled examples while the model itself stays untouched.
10
+ """
11
+
12
+ from .client import AsyncTypeSafeClient, TypeSafeClient
13
+ from .context import Context
14
+ from .presets import PRESETS, Preset
15
+ from .tuning import TuningReport
16
+ from .types import (Choice, ChoiceAnswer, Noul, NoulAnswer, NoulCriteria, Score, ScoreAnswer,
17
+ SystemOneResponse, Usage)
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "TypeSafeClient", "AsyncTypeSafeClient",
23
+ "Choice", "Noul", "NoulCriteria", "Score",
24
+ "ChoiceAnswer", "NoulAnswer", "ScoreAnswer", "SystemOneResponse", "Usage",
25
+ "Context", "TuningReport", "Preset", "PRESETS", "__version__",
26
+ ]
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "minicpm5-2b",
3
+ "repos": {
4
+ "torch": "openbmb/MiniCPM5-2B"
5
+ },
6
+ "backend": "torch",
7
+ "formulations": [
8
+ {
9
+ "name": "one_word",
10
+ "template": "This text: \"{state}\" means in one word: \"",
11
+ "layer": 41
12
+ },
13
+ {
14
+ "name": "question_options",
15
+ "template": "{instructions}\nPossible answers: {options}.\nText: \"{state}\"\nIn one word, the answer is: \"",
16
+ "layer": 37
17
+ }
18
+ ],
19
+ "tau": 0.04381,
20
+ "center": "generic",
21
+ "one_word": [
22
+ 37,
23
+ 0.03975
24
+ ],
25
+ "latency_ms": "~155",
26
+ "quality": "dev accuracy 0.540 ± 0.035 (n=200); not measured on the Jev bench",
27
+ "notes": "Fitted by `jul models add` on 2026-09-22.",
28
+ "calibration": {
29
+ "layers": {
30
+ "one_word": 41,
31
+ "question_options": 37,
32
+ "one_word_only": 37
33
+ },
34
+ "center": "generic",
35
+ "tau": 0.043805317199998264,
36
+ "tau_one_word": 0.03975009964560149,
37
+ "tau_task_center": 0.04132507636642313,
38
+ "dev_accuracy": 0.54,
39
+ "dev_accuracy_stderr": 0.035242020373412196,
40
+ "dev_accuracy_task_center": 0.595,
41
+ "dev_ece": 0.15921275988221167,
42
+ "dev_accuracy_by_center": {
43
+ "options": 0.5361,
44
+ "generic": 0.55,
45
+ "none": 0.5506
46
+ },
47
+ "dev_accuracy_by_set": {
48
+ "yahootopics": 0.42,
49
+ "empathetic": 0.42,
50
+ "massive": 0.6,
51
+ "financialphrasebank": 0.72
52
+ },
53
+ "n_dev": 200,
54
+ "backend": "torch",
55
+ "repo": "openbmb/MiniCPM5-2B",
56
+ "n_layers": 42,
57
+ "candidate_layers": [
58
+ 20,
59
+ 41
60
+ ],
61
+ "n_generic": 195,
62
+ "latency_ms_p50": 154.81362499849638,
63
+ "warnings": [],
64
+ "date": "2026-09-22",
65
+ "seconds": 141
66
+ }
67
+ }
jul/backbone.py ADDED
@@ -0,0 +1,215 @@
1
+ """Backbone: one forward pass, tap hidden states at chosen layers, reuse a cached prompt prefix.
2
+
3
+ The instruction part of every prompt is identical across calls, so its KV cache is computed once
4
+ and each query only pays for its own tokens. When only intermediate layers are needed, the forward
5
+ stops right after the deepest one (the remaining layers are never computed).
6
+
7
+ The framework lives behind `Backbone`: `backends/mlx.py` (Apple Silicon) and `backends/torch.py`
8
+ (transformers: CUDA, CPU, MPS). Everything above this module only sees numpy arrays.
9
+ `Backbone(name)` picks the backend from `JUL_BACKEND`, else MLX when available, else torch.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
15
+ import os
16
+ import platform
17
+ import time
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+
23
+ BACKENDS = ("mlx", "torch")
24
+
25
+ #: Preset name -> repo per backend. A name missing here is used as the repo itself.
26
+ MODELS: dict[str, dict[str, str]] = {
27
+ "minicpm5-2b": {"mlx": "openbmb/MiniCPM5-2B-MLX", "torch": "openbmb/MiniCPM5-2B"},
28
+ "qwen3-0.6b": {"mlx": "mlx-community/Qwen3-0.6B-4bit", "torch": "Qwen/Qwen3-0.6B"},
29
+ "qwen3-1.7b": {"mlx": "mlx-community/Qwen3-1.7B-4bit", "torch": "Qwen/Qwen3-1.7B"},
30
+ "qwen3.5-9b": {"mlx": "mlx-community/Qwen3.5-9B-4bit", "torch": "Qwen/Qwen3.5-9B"},
31
+ }
32
+
33
+ #: Size of a group in `PromptTemplate.run_batch`: rows x longest prompt (cached prefix included).
34
+ #: `JUL_BATCH_TOKENS` / `JUL_BATCH_SIZE` override them, read at each call like the other JUL_* variables.
35
+ BATCH_TOKENS = 16384
36
+ BATCH_SIZE = 64
37
+
38
+ # Layers tapped during extraction, as fractions of the model depth.
39
+ DEFAULT_LAYER_FRACTIONS = (0.25, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0)
40
+
41
+
42
+ def _mlx_available() -> bool:
43
+ return (platform.system() == "Darwin" and platform.machine() == "arm64"
44
+ and importlib.util.find_spec("mlx") is not None)
45
+
46
+
47
+ def resolve_backend(backend: str | None = None) -> str:
48
+ backend = backend or os.environ.get("JUL_BACKEND")
49
+ if backend:
50
+ if backend not in BACKENDS:
51
+ raise ValueError(f"Unknown backend {backend!r}. Available: {', '.join(BACKENDS)}")
52
+ return backend
53
+ if _mlx_available():
54
+ return "mlx"
55
+ if importlib.util.find_spec("torch") is not None:
56
+ return "torch"
57
+ raise ImportError("No backend installed: pip install 'jul[mlx]' (Apple Silicon) or 'jul[torch]'")
58
+
59
+
60
+ def model_key(name: str, backend: str) -> str:
61
+ """Identifies vectors computed by a model on a backend (saved centers, heads, calibrations).
62
+
63
+ MLX keeps the bare name so that contexts saved before backends existed stay valid.
64
+ """
65
+ return name if backend == "mlx" else f"{name}@{backend}"
66
+
67
+
68
+ def repo_for(name: str, backend: str) -> str:
69
+ repos = MODELS.get(name)
70
+ if repos is None:
71
+ return name
72
+ if backend not in repos:
73
+ raise ValueError(f"{name!r} has no {backend} repo")
74
+ return repos[backend]
75
+
76
+
77
+ class Backbone:
78
+ """A causal LM read by the vector method. `Backbone(name)` returns the resolved backend's subclass.
79
+
80
+ Subclasses set `tokenizer` (a Hugging Face tokenizer, for `encode` and the chat template) and
81
+ `n_layers`, and implement `forward` and `cache_prefix`.
82
+ """
83
+
84
+ backend: str = ""
85
+
86
+ def __new__(cls, name: str, backend: str | None = None, **kwargs):
87
+ if cls is Backbone:
88
+ backend = resolve_backend(backend)
89
+ if backend == "mlx":
90
+ from .backends.mlx import MLXBackbone as cls
91
+ else:
92
+ from .backends.torch import TorchBackbone as cls
93
+ return super().__new__(cls)
94
+
95
+ def __init__(self, name: str, backend: str | None = None):
96
+ self.name = name
97
+ self.repo = repo_for(name, self.backend)
98
+ self.key = model_key(name, self.backend)
99
+
100
+ def layer_indices(self, fractions=DEFAULT_LAYER_FRACTIONS) -> list[int]:
101
+ return sorted({max(0, min(self.n_layers - 1, round(f * self.n_layers) - 1)) for f in fractions})
102
+
103
+ def encode(self, text: str) -> list[int]:
104
+ return self.tokenizer.encode(text, add_special_tokens=False)
105
+
106
+ def forward(self, tokens: list[int], layers=(), logits=False, pool: tuple[int, int] | None = None,
107
+ prefix=None) -> tuple[dict[int, np.ndarray], np.ndarray | None]:
108
+ """Run tokens through the model, after `prefix` (from `cache_prefix`) when given.
109
+
110
+ Returns ({layer: (2d,) float32 features}, float32 last-token logits or None). Features are the
111
+ last-token hidden state concatenated with the mean hidden state over positions
112
+ pool=(start, end) of `tokens`. The prefix is left as it was, ready for the next query.
113
+ """
114
+ raise NotImplementedError
115
+
116
+ def forward_batch(self, queries: list[list[int]], layers=(), pools: list | None = None,
117
+ prefix=None) -> list[dict[int, np.ndarray]]:
118
+ """The features of `forward` for each query, all after the same `prefix`. A backend may run
119
+ them in one forward; this default runs them one by one."""
120
+ pools = pools or [None] * len(queries)
121
+ return [self.forward(q, layers=layers, pool=p, prefix=prefix)[0] for q, p in zip(queries, pools)]
122
+
123
+ def cache_prefix(self, tokens: list[int]):
124
+ """Run `tokens` once and keep the model state after them, for `forward(prefix=...)`."""
125
+ raise NotImplementedError
126
+
127
+ def last_hidden(self, tokens: list[int], prefix=None) -> np.ndarray:
128
+ """(len(tokens), d) float32: the last layer's hidden states after the final norm, for every token
129
+ of `tokens` (run after `prefix` when given). The prefix is left as it was. Used by the pointer
130
+ method (jul/decision.py)."""
131
+ raise NotImplementedError
132
+
133
+ @property
134
+ def model_dir(self) -> Path:
135
+ """Local directory of the weights (downloaded on first access for a Hub repo)."""
136
+ if Path(self.repo).is_dir():
137
+ return Path(self.repo)
138
+ from huggingface_hub import snapshot_download
139
+ return Path(snapshot_download(self.repo))
140
+
141
+
142
+ @dataclass
143
+ class PromptTemplate:
144
+ """A chat prompt split around the user input: a cached prefix and a per-query suffix."""
145
+
146
+ backbone: Backbone
147
+ prefix_text: str
148
+ suffix_text: str
149
+ use_prefix_cache: bool = True
150
+
151
+ SENTINEL = "⁣QF_INPUT⁣"
152
+
153
+ @classmethod
154
+ def from_user_message(cls, backbone: Backbone, message: str, **kw) -> "PromptTemplate":
155
+ """`message` must contain {input}; it is rendered with the model's chat template."""
156
+ rendered = backbone.tokenizer.apply_chat_template(
157
+ [{"role": "user", "content": message.replace("{input}", cls.SENTINEL)}],
158
+ tokenize=False,
159
+ add_generation_prompt=True,
160
+ enable_thinking=False,
161
+ )
162
+ prefix, suffix = rendered.split(cls.SENTINEL)
163
+ return cls(backbone, prefix, suffix, **kw)
164
+
165
+ def __post_init__(self):
166
+ self.prefix_tokens = self.backbone.encode(self.prefix_text)
167
+ self._n_suffix = len(self.backbone.encode(self.suffix_text))
168
+ self._prefix = None
169
+ if self.use_prefix_cache and self.prefix_tokens:
170
+ self._prefix = self.backbone.cache_prefix(self.prefix_tokens)
171
+
172
+ def run(self, text: str, layers=(), logits=False):
173
+ query = self.backbone.encode(text + self.suffix_text)
174
+ n_input = max(1, len(query) - self._n_suffix)
175
+ if self._prefix is None:
176
+ p = len(self.prefix_tokens)
177
+ return self.backbone.forward(self.prefix_tokens + query, layers=layers, logits=logits, pool=(p, p + n_input))
178
+ return self.backbone.forward(query, layers=layers, logits=logits, pool=(0, n_input), prefix=self._prefix)
179
+
180
+ def run_batch(self, texts: list[str], layers=()) -> list[dict[int, np.ndarray]]:
181
+ """The features of `run` for each text. Texts are sorted by length and grouped so that a group
182
+ holds at most BATCH_TOKENS tokens (rows x longest prompt) and BATCH_SIZE rows."""
183
+ queries = [self.backbone.encode(t + self.suffix_text) for t in texts]
184
+ n_inputs = [max(1, len(q) - self._n_suffix) for q in queries]
185
+ p = len(self.prefix_tokens)
186
+ if self._prefix is None:
187
+ seqs, pools = [self.prefix_tokens + q for q in queries], [(p, p + n) for n in n_inputs]
188
+ else:
189
+ seqs, pools = queries, [(0, n) for n in n_inputs]
190
+ max_tokens = int(os.environ.get("JUL_BATCH_TOKENS") or BATCH_TOKENS)
191
+ max_rows = int(os.environ.get("JUL_BATCH_SIZE") or BATCH_SIZE)
192
+ out: list = [None] * len(seqs)
193
+ group: list[int] = []
194
+
195
+ def flush():
196
+ got = self.backbone.forward_batch([seqs[i] for i in group], layers=layers,
197
+ pools=[pools[i] for i in group], prefix=self._prefix)
198
+ for i, features in zip(group, got):
199
+ out[i] = features
200
+
201
+ for i in sorted(range(len(seqs)), key=lambda i: len(seqs[i])):
202
+ longest = len(seqs[i]) + (p if self._prefix is not None else 0)
203
+ if group and ((len(group) + 1) * longest > max_tokens or len(group) >= max_rows):
204
+ flush()
205
+ group = []
206
+ group.append(i)
207
+ if group:
208
+ flush()
209
+ return out
210
+
211
+
212
+ def timed(fn, *args, **kwargs):
213
+ t = time.perf_counter()
214
+ out = fn(*args, **kwargs)
215
+ return out, time.perf_counter() - t
@@ -0,0 +1 @@
1
+ """Framework-specific implementations of `jul.backbone.Backbone`. Imported lazily by `Backbone(name)`."""
jul/backends/mlx.py ADDED
@@ -0,0 +1,194 @@
1
+ """MLX backend (Apple Silicon), through mlx-lm.
2
+
3
+ `forward_batch` runs several queries in one forward, as the torch backend does: padded on the right,
4
+ under the causal mask alone, since a real token never sees the padding after it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ import mlx.core as mx
14
+ import numpy as np
15
+ from mlx_lm import load
16
+ from mlx_lm.models.cache import KVCache, can_trim_prompt_cache, make_prompt_cache
17
+
18
+ from ..backbone import Backbone
19
+
20
+
21
+ class _StopForward(Exception):
22
+ pass
23
+
24
+
25
+ def _rope_fix(repo: str) -> dict | None:
26
+ """transformers 5 writes the RoPE base under `rope_parameters`; mlx-lm reads `rope_theta` and
27
+ silently falls back to 10000 when it is missing, which gives wrong answers without any error
28
+ (JOURNAL §9 duovicies). Pass the right value when a converted config has only the new key."""
29
+ path = Path(repo) / "config.json"
30
+ if not path.exists():
31
+ # a Hub repo: the cached config, else fetch that one file (1 KB, before the weights)
32
+ from huggingface_hub import hf_hub_download, try_to_load_from_cache
33
+ cached = try_to_load_from_cache(repo, "config.json")
34
+ try:
35
+ path = Path(cached if isinstance(cached, str) else hf_hub_download(repo, "config.json"))
36
+ except Exception:
37
+ return None
38
+ config = json.loads(path.read_text())
39
+ theta = (config.get("rope_parameters") or {}).get("rope_theta")
40
+ return {"rope_theta": theta} if theta and "rope_theta" not in config else None
41
+
42
+
43
+ class _Tap:
44
+ """Wraps a transformer block to record its features and optionally halt the forward."""
45
+
46
+ def __init__(self, block, idx: int, backbone: "MLXBackbone"):
47
+ self.block = block
48
+ self.idx = idx
49
+ self.bb = backbone
50
+
51
+ def __call__(self, *args, **kwargs):
52
+ h = self.block(*args, **kwargs)
53
+ if self.idx in self.bb._want:
54
+ pool = self.bb._pool
55
+ last = h[mx.arange(h.shape[0]), self.bb._last]
56
+ # mean in float32, rounded back to the model dtype like `h[:, start:end].mean(1)` was
57
+ mean = (mx.where(pool, h, 0).astype(mx.float32).sum(1) / pool.sum(1)).astype(h.dtype)
58
+ # [last token ; mean over the input tokens]
59
+ self.bb._captured[self.idx] = mx.concatenate([last, mean], axis=-1)
60
+ if self.bb._stop_at == self.idx:
61
+ raise _StopForward
62
+ return h
63
+
64
+ def __getattr__(self, name):
65
+ return getattr(self.block, name)
66
+
67
+
68
+ @dataclass
69
+ class _Prefix:
70
+ n: int
71
+ cache: list | None = None # attention KV cache: trimmed back to the prefix after each query
72
+ snapshot: list | None = None # recurrent state: copied, each query starts from the copy
73
+
74
+
75
+ class MLXBackbone(Backbone):
76
+ backend = "mlx"
77
+
78
+ def __init__(self, name: str, backend: str | None = None):
79
+ super().__init__(name)
80
+ self.model, self.tokenizer = load(self.repo, model_config=_rope_fix(self.repo))
81
+ layers = self.model.layers
82
+ for i, block in enumerate(layers):
83
+ layers[i] = _Tap(block, i, self)
84
+ self.n_layers = len(layers)
85
+ self._want: set[int] = set()
86
+ self._stop_at: int | None = None
87
+ self._captured: dict[int, mx.array] = {}
88
+ self._last: mx.array | None = None # (B,) index of each row's last real token
89
+ self._pool: mx.array | None = None # (B, T, 1) bool mask of each row's pooled positions
90
+ pad = getattr(self.tokenizer, "pad_token_id", None)
91
+ self._pad = pad if pad is not None else (self.tokenizer.eos_token_id or 0)
92
+ lm = getattr(self.model, "language_model", self.model) # multimodal wrappers (e.g. Qwen3.5) nest the text model
93
+ self._inner = lm.model
94
+ self._lm_head = lm.lm_head if hasattr(lm, "lm_head") else self._inner.embed_tokens.as_linear
95
+
96
+ def forward(self, tokens, layers=(), logits=False, pool=None, prefix: _Prefix | None = None, cache=None):
97
+ """`cache` (an mlx-lm prompt cache, advanced in place) is kept for the dev scripts."""
98
+ if prefix is None:
99
+ return self._first(self._run([tokens], cache, layers, logits, [pool]))
100
+ if prefix.snapshot is not None:
101
+ return self._first(self._run([tokens], self._restored(prefix.snapshot), layers, logits, [pool]))
102
+ try:
103
+ return self._first(self._run([tokens], prefix.cache, layers, logits, [pool]))
104
+ finally:
105
+ for c in prefix.cache:
106
+ if c.offset > prefix.n:
107
+ c.trim(c.offset - prefix.n)
108
+
109
+ def forward_batch(self, queries, layers=(), pools=None, prefix: _Prefix | None = None):
110
+ pools = pools or [None] * len(queries)
111
+ if len(queries) == 1 or (prefix is not None and not _repeatable(prefix)):
112
+ # a recurrent state (or a rotating window) is not repeated over a batch: one query at a time
113
+ return super().forward_batch(queries, layers, pools, prefix)
114
+ cache = None
115
+ if prefix is not None:
116
+ # a fresh cache holding the prefix once per row; the template's own cache is not touched
117
+ cache = make_prompt_cache(self.model)
118
+ for new, c in zip(cache, prefix.cache):
119
+ keys, values = c.state
120
+ new.state = (mx.repeat(keys, len(queries), axis=0), mx.repeat(values, len(queries), axis=0))
121
+ captured, _ = self._run(queries, cache, layers, False, pools)
122
+ # every group has its own shape (rows x width): MLX would keep the freed buffers of each one
123
+ mx.clear_cache()
124
+ return [{k: v[i] for k, v in captured.items()} for i in range(len(queries))]
125
+
126
+ def cache_prefix(self, tokens) -> _Prefix:
127
+ cache = make_prompt_cache(self.model)
128
+ self._run([tokens], cache, logits=True)
129
+ mx.eval([c.state for c in cache])
130
+ if can_trim_prompt_cache(cache):
131
+ return _Prefix(len(tokens), cache=cache)
132
+ # Recurrent state (e.g. Gated DeltaNet in Qwen3.5) cannot be trimmed: keep a copy of the state
133
+ # after the prefix. Layers replace their state arrays rather than writing into them, so the
134
+ # copy is never modified.
135
+ return _Prefix(len(tokens), snapshot=[tuple(c.state) for c in cache])
136
+
137
+ def last_hidden(self, tokens, prefix: _Prefix | None = None) -> np.ndarray:
138
+ self._want, self._stop_at = set(), None
139
+ cache = None
140
+ if prefix is not None:
141
+ cache = self._restored(prefix.snapshot) if prefix.snapshot is not None else prefix.cache
142
+ try:
143
+ h = self._inner(mx.array(tokens)[None], cache=cache)[0].astype(mx.float32)
144
+ mx.eval(h)
145
+ return np.array(h)
146
+ finally:
147
+ if prefix is not None and prefix.cache is not None:
148
+ for c in prefix.cache:
149
+ if c.offset > prefix.n:
150
+ c.trim(c.offset - prefix.n)
151
+
152
+ def _restored(self, snapshot):
153
+ cache = make_prompt_cache(self.model)
154
+ for c, state in zip(cache, snapshot):
155
+ c.state = list(state)
156
+ return cache
157
+
158
+ @staticmethod
159
+ def _first(result):
160
+ captured, logits = result
161
+ return {k: v[0] for k, v in captured.items()}, (logits[0] if logits is not None else None)
162
+
163
+ def _run(self, seqs, cache=None, layers=(), logits=False, pools=None):
164
+ """Right-padded batch of token lists. Returns ({layer: (B, 2d)}, (B, vocab) or None)."""
165
+ lengths = [len(s) for s in seqs]
166
+ width = max(lengths)
167
+ ids = np.full((len(seqs), width), self._pad, dtype=np.int32)
168
+ pool = np.zeros((len(seqs), width, 1), dtype=bool)
169
+ for i, (s, p) in enumerate(zip(seqs, pools or [None] * len(seqs))):
170
+ ids[i, : len(s)] = s
171
+ start, end = p or (0, len(s))
172
+ pool[i, start:end] = True
173
+ self._want = set(layers)
174
+ self._last = mx.array([n - 1 for n in lengths])
175
+ self._pool = mx.array(pool)
176
+ self._stop_at = None if logits else (max(layers) if layers else None)
177
+ self._captured = {}
178
+ out = None
179
+ try:
180
+ h = self._inner(mx.array(ids), cache=cache)
181
+ if logits:
182
+ out = self._lm_head(h[mx.arange(len(seqs)), self._last]).astype(mx.float32)
183
+ except _StopForward:
184
+ pass
185
+ captured = {k: v.astype(mx.float32) for k, v in self._captured.items()}
186
+ mx.eval(list(captured.values()) + ([out] if out is not None else []))
187
+ return ({k: np.array(v) for k, v in captured.items()},
188
+ np.array(out) if out is not None else None)
189
+
190
+
191
+ def _repeatable(prefix: _Prefix) -> bool:
192
+ """A plain KV cache can be copied once per row of a batch; a recurrent state or a rotating window
193
+ goes through `forward`, one query at a time."""
194
+ return prefix.cache is not None and all(type(c) is KVCache for c in prefix.cache)