ph-text-index 0.1.0__tar.gz

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.
@@ -0,0 +1,31 @@
1
+ # Build and environment
2
+ .venv/
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ __pycache__/
7
+ *.py[cod]
8
+ jjt/
9
+ w2/
10
+
11
+ # Tooling caches
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ .coverage
16
+ # Written by the guest's subprocess collectors and combined at the end of the
17
+ # run; see `conftest.GUEST_COVERAGE_RC`.
18
+ .coverage-guest*
19
+ htmlcov/
20
+ # Dropped at the repo root by pytest-textual-snapshot when a snapshot test
21
+ # fails. The reference snapshots under `__snapshots__/` are the committed
22
+ # expectation; this is the diff viewer for a run that did not match one.
23
+ snapshot_report.html
24
+
25
+ # Reference checkouts of the upstream projects this port reads from. Vendored
26
+ # locally so the plans' citations are verifiable; never part of this repo.
27
+ sources/
28
+
29
+ # Local scratch
30
+ .ph/
31
+ *.local.yaml
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Charles Tabor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,342 @@
1
+ Metadata-Version: 2.5
2
+ Name: ph-text-index
3
+ Version: 0.1.0
4
+ Summary: pH plugin: `text_index` and `text_search`, semantic retrieval over documents on a turbovec index.
5
+ Project-URL: Homepage, https://github.com/chastabor/pH
6
+ Project-URL: Repository, https://github.com/chastabor/pH
7
+ Project-URL: Documentation, https://github.com/chastabor/pH/blob/main/docs/README.md
8
+ Project-URL: Issues, https://github.com/chastabor/pH/issues
9
+ Author: Charles Tabor
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agent,embeddings,llm,retrieval,semantic-search
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Classifier: Topic :: Software Development
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: numpy>=1.20
25
+ Requires-Dist: ph-core==0.1.0
26
+ Requires-Dist: turbovec>=1.0
27
+ Provides-Extra: local
28
+ Requires-Dist: sentence-transformers>=3.0; extra == 'local'
29
+ Requires-Dist: torch>=2.2; extra == 'local'
30
+ Provides-Extra: nomic
31
+ Requires-Dist: einops>=0.7; extra == 'nomic'
32
+ Requires-Dist: sentence-transformers>=3.0; extra == 'nomic'
33
+ Requires-Dist: torch>=2.2; extra == 'nomic'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # ph-text-index
37
+
38
+ *`text_index` and `text_search`: semantic retrieval over the agent's own
39
+ documents, on a local [turbovec][turbovec] index.*
40
+
41
+ `grep` finds the string you typed. This finds the passage you meant — and hands
42
+ back the `path:start-end` it came from, so the model can answer from the passage
43
+ or `read` the file around it.
44
+
45
+ ```bash
46
+ phern --profile llama --provider llama --model <model> \
47
+ --patch '{insert: [{id: text-index, name: text-index},
48
+ {id: text-index-local, name: text-index-local}]}' \
49
+ -p "index docs/ then tell me how the workspace seam decides a containment tier"
50
+ ```
51
+
52
+ ## Two rows, and why
53
+
54
+ | row | what it is |
55
+ |---|---|
56
+ | `text-index` | the seam: `ctx.text_index`, the chunker, the turbovec store, and the two tools |
57
+ | `text-index-local` | the provider: a `sentence-transformers` model, loaded on first use |
58
+
59
+ The split is the shape `subprocess`/`subprocess-local` and
60
+ `code-runtime`/`code-runtime-python` already have in this tree, and it pays for
61
+ itself three times:
62
+
63
+ * a deployment with an embeddings **endpoint** — llama.cpp's `/v1/embeddings`, a
64
+ provider's API — writes its own row and keeps the tools;
65
+ * the **tests** run the real turbovec index against a deterministic stub
66
+ embedder, so the suite proves chunking, paging and persistence without
67
+ downloading a model;
68
+ * the row that pulls in **torch** is one a profile can leave out.
69
+
70
+ **The tools appear only once an embedder is claimed.** `TextIndexSeam.register`
71
+ registers them on the provider's own scope, so a profile with the seam and no
72
+ provider advertises nothing at all — the rule `subagent-task` states, for the
73
+ same reason: a tool named in every prompt and refused on every call spends
74
+ context teaching the model a capability the deployment does not have.
75
+
76
+ ## The two tools
77
+
78
+ **`text_index(paths, glob?, forget?)`** reads each document through `ctx.fs`,
79
+ cuts it into passages, embeds them and stores them. Re-indexing a file replaces
80
+ its passages, so running it again after an edit is correct. `forget: true`
81
+ removes instead.
82
+
83
+ **Nothing watches the filesystem** — no daemon, no watcher. The index changes
84
+ when `text_index` runs and at no other moment, so run it again after edits.
85
+
86
+ That is cheap now because **git or jj is asked first** (`ph.seams.changes`): a
87
+ document the version control vouches for is not read, not re-cut, and — the part
88
+ that matters — **not re-embedded**. This loop has no content digest of its own,
89
+ so before the filter every call re-embedded the whole corpus: 20 s of MiniLM for
90
+ this repo's `docs/`, or 185 s under nomic. Which backend answers is the
91
+ workspace provider's to state; a tree with no version control behaves exactly as
92
+ it did before, which is a test rather than a hope. A document past `max_bytes` is *skipped and
93
+ reported*, not an error — a call over a directory should not fail because it
94
+ found a minified bundle, and a caller who never learns what was skipped cannot
95
+ tell a quiet corpus from a quiet failure.
96
+
97
+ **`text_search(query, k?, paths?)`** returns the passages with their line
98
+ ranges and scores. `paths` restricts the search to a subtree.
99
+
100
+ An earlier version of this paragraph claimed a filtered search costs *less* than
101
+ an unfiltered one, because turbovec filters inside the SIMD kernel. Measured,
102
+ that is wrong: the kernel does, but building the id list on the Python side
103
+ dominates it by roughly fifteen times (0.11 ms against a 0.22 ms unfiltered
104
+ search on a 10 000-chunk index, and selectivity changes nothing). Use `paths`
105
+ for **precision**, not for speed.
106
+
107
+ ## Everything is read through `ctx.fs`
108
+
109
+ Every indexed byte arrives via `ctx.fs.read`, so `fs/read-intent` fires,
110
+ `permissions-fs` decides, the workspace tier bounds the path and every
111
+ registered screen gets its say — the same door `read` goes through, and for the
112
+ reason `tool-attach` insists on it (I-9). It matters more here, because
113
+ indexing is a *bulk* read: a tool that walked the tree with `Path.open` would be
114
+ an exfiltration primitive with a glob argument.
115
+
116
+ The corollary: the index is **per-deployment, not per-agent**. Two agents share
117
+ `$PH_CACHE/text-index/<embedder digest>` unless a profile says otherwise, so a
118
+ passage one indexed is retrievable by another. Right for a documentation corpus,
119
+ wrong for anything private — `path:` in the row's config is how you separate
120
+ them.
121
+
122
+ ## Chunking
123
+
124
+ Paragraphs, not a fixed character stride. A stride is simpler and reliably
125
+ splits the one sentence that answers the query across two chunks, so neither
126
+ retrieves; blank lines are where the document's own author already said "new
127
+ idea". Every chunk carries its 1-based line span, because a chunk that does not
128
+ know its place in the file is a wall of prose the agent must then go and locate.
129
+ `overlap_chars` carries the previous chunk's tail forward, which is the
130
+ concession to a paragraph whose meaning depends on the one before it.
131
+
132
+ ## On disk
133
+
134
+ `index.tvim` is turbovec's own format, written with `sync()` — incremental, one
135
+ fsync per call, crash-safe at any byte. `chunks.json` is the sidecar: path, line
136
+ span and the passage text.
137
+
138
+ **The sidecar holds the text on purpose.** Storing only pointers would keep the
139
+ file small and make every hit a second tool call before the model learns whether
140
+ the hit was any good — answered from a file that may have changed since it was
141
+ indexed. It is also the ceiling: the sidecar is rewritten whole on every save,
142
+ which is fine for a corpus of documents and wrong for millions.
143
+
144
+ A crash between the two writes can leave them diverged, so vectors are committed
145
+ first and `open` reconciles by trusting the sidecar and dropping any id the index
146
+ cannot answer for — a chunk nobody can retrieve is invisible, where a vector
147
+ with no text would surface as a hit this row could not describe.
148
+
149
+ ### Calibration
150
+
151
+ turbovec's TQ+ calibration is worth 2.5 to 8.7 points of R@10, and upstream is
152
+ emphatic about the one way to get it wrong: the sample must be a uniform random,
153
+ representative draw of what the index will hold, and a clustered prefix "fits a
154
+ calibration that actively destroys recall". An incremental indexer has no such
155
+ draw at the moment it would have to commit one — the first document is the most
156
+ clustered prefix there is.
157
+
158
+ So it is committed in exactly the situation upstream describes: the index is
159
+ **empty** and the incoming batch is large enough (1 024 rows) to sample from,
160
+ in which case a random sample of that batch *is* a representative draw and
161
+ calibrating before the add is the documented order. Otherwise the index stays
162
+ uncalibrated, which is plain TurboQuant — good rather than wrong. `calibrate:
163
+ false` turns even that off.
164
+
165
+ ## Using it from the RLM
166
+
167
+ Nothing to do: under Code Mode every registered tool is in the generated SDK
168
+ listing, so these arrive as `await tools.text_index(...)` and
169
+ `await tools.text_search(...)` with no work from this package. The
170
+ [`rlm-indexed`](../ph-app/src/ph_app/profiles.py) profile is `rlm-stable` plus
171
+ this bundle and `ph-code-graph`'s:
172
+
173
+ ```bash
174
+ phern --profile rlm-indexed --provider llama --model <model> --mode tui
175
+ ```
176
+
177
+ This package registers a `ph.bundles` entry point so that profile is *hidden*
178
+ on an install without this distribution rather than offered and then failing at
179
+ mount — `available_profiles()` gates on bundle resolution, and the refusal names
180
+ the package to install.
181
+
182
+ The bundle carries **both** rows, and that matters: the seam registers no tools
183
+ until an embedder is claimed, so a bundle shipping only `text-index` would mount
184
+ a service and advertise nothing, with no error anywhere. A deployment bringing
185
+ its own embedder disables `text-index-local` and mounts its own provider row.
186
+
187
+ ## Installing
188
+
189
+ `sentence-transformers` is a **hard dependency**, so installing this package is
190
+ how a deployment gets a working index. That is most of a gigabyte of torch, in
191
+ every environment that resolves the package, CI included. Moving it to an
192
+ extra is one line in `pyproject.toml`; the cost of that is that
193
+ `pip install ph-text-index` no longer gives you something that runs. The
194
+ seam/provider split keeps the choice cheap either way — nothing but
195
+ `text-index-local` imports it.
196
+
197
+ ## Switching models
198
+
199
+ The default is **`sentence-transformers/all-MiniLM-L6-v2`** — 384-dimensional,
200
+ about 90 MB, symmetric so it needs no prefixes, and fast enough on a CPU that
201
+ indexing a documentation tree is a coffee break. It is the *small* choice, not
202
+ the best one.
203
+
204
+ The index directory is keyed by a digest of the embedder's identity — model
205
+ name **and** both prefixes, because all three move the vector space — so
206
+ switching is safe to try: a new model gets its own index and switching back
207
+ finds the old one intact. Pointing `path:` at a fixed directory and then
208
+ changing the model raises `IndexMismatch` rather than returning neighbours
209
+ computed in a space nothing shares.
210
+
211
+ ### A worked upgrade: `nomic-embed-text-v1.5` — measured
212
+
213
+ Run against this repository's `docs/seams` (30 documents, 235 passages), same
214
+ queries, same chunking:
215
+
216
+ | | MiniLM-L6-v2 | nomic-embed-text-v1.5 |
217
+ |---|---|---|
218
+ | dimensions | 384 | 768 |
219
+ | load, cold | 19 s | 26 s |
220
+ | index 235 passages | **5.4 s** | 41.7 s |
221
+ | *"stop an agent writing in my own checkout"* | workspace.md (0.319) | workspace.md (**0.599**) |
222
+ | *"what confines a shell command on linux"* | shell.md (0.529) | shell.md (**0.753**) |
223
+
224
+ On a harder corpus — all 57 files of `docs/`, 539 passages, eight paraphrase
225
+ queries scored on whether the top hit was the right *file* — nomic got **3/8**
226
+ against MiniLM's **1/8**, at 185 s of indexing against 20 s.
227
+
228
+ Read that honestly in both directions. nomic retrieves better and separates hits
229
+ far more confidently; it also costs roughly **8× the indexing time**, and 3/8 is
230
+ not good retrieval in absolute terms. Some of those misses are the scoring being
231
+ crude (a filename substring), but not all of them: a corpus of long design notes
232
+ is genuinely hard, and *narrowing the corpus* helped more than changing the model
233
+ — MiniLM found `workspace.md` over `docs/seams` and missed it over all of
234
+ `docs/`. Use `paths=` before reaching for a bigger model.
235
+
236
+ **MiniLM stays the default** for that reason: the cheap thing is good enough for
237
+ a scoped corpus, and the expensive thing does not rescue an unscoped one.
238
+
239
+
240
+
241
+ 768-dimensional, 8192-token context, materially better retrieval than MiniLM.
242
+ Three things it needs, and each is a field rather than a special case:
243
+
244
+ ```bash
245
+ pip install 'ph-text-index[nomic]' # einops, which its remote code imports
246
+ ```
247
+
248
+ ```yaml
249
+ - id: text-index-local
250
+ config:
251
+ model: nomic-ai/nomic-embed-text-v1.5
252
+ # Asymmetric: it wants to know whether it is embedding a question or a
253
+ # passage. Both prefixes are part of the index identity, so this gets its
254
+ # own directory automatically.
255
+ queryPrefix: "search_query: "
256
+ documentPrefix: "search_document: "
257
+ # Its config.json carries an `auto_map` pointing at nomic-ai/nomic-bert-2048,
258
+ # so sentence-transformers will not load it without this. Read the next
259
+ # paragraph before setting it.
260
+ trustRemoteCode: true
261
+ ```
262
+
263
+ **`trustRemoteCode` is a trust decision, not a compatibility flag.** It
264
+ downloads Python from the model's repository — here from a *second* repository
265
+ via `auto_map` — and executes it in the harness process. It is off by default
266
+ and nothing infers it from a load failure, because "retry with arbitrary code
267
+ execution enabled" is not a fallback. It is deliberately *not* part of the index
268
+ identity: it governs what may load, not where a vector lands, so granting or
269
+ revoking it does not invalidate an index.
270
+
271
+ A model whose weights are safetensors and whose architecture `transformers`
272
+ already knows needs none of this — `BAAI/bge-base-en-v1.5` and the `e5` family
273
+ are drop-in with prefixes alone.
274
+
275
+ **And the `[nomic]` extra is not optional politeness.** Without `einops` the
276
+ model downloads its weights *and* its remote code successfully and then dies at
277
+ import:
278
+
279
+ ```
280
+ ImportError: This modeling file requires the following packages that were not
281
+ found in your environment: einops
282
+ ```
283
+
284
+ A missing transitive dependency that only the model's own code knows about, and
285
+ that no amount of pre-downloading would have caught. It is the reason
286
+ provisioning **loads** the model rather than fetching it — see below.
287
+
288
+ ## Where the weights live, and installing on purpose
289
+
290
+ `$PH_CACHE/models`, set by the row (`cache:` overrides it). Left to
291
+ `sentence-transformers` they would go to `$HF_HOME` or `~/.cache/huggingface` —
292
+ outside all three of pH's roots, so a gigabyte of weights would sit somewhere
293
+ `phern doctor` never mentions and `rm -rf $PH_CACHE` would not reclaim. Rebuildable
294
+ and large is the lifecycle `$PH_CACHE` names (Q1), and the runtime venv is there
295
+ for the same reason.
296
+
297
+ **Provision before the agent needs it**, three ways, in increasing order of
298
+ "nobody is watching":
299
+
300
+ ```
301
+ /text-index status # is the model ready? costs nothing, downloads nothing
302
+ /text-index install # fetch and load it now, and say what happened
303
+ ```
304
+
305
+ A **command**, not a tool: a person asks the harness to do this, and routing it
306
+ through a model turn would put the model in the log as having decided it. It
307
+ costs no turn. `phern doctor` answers the same question without mounting an agent —
308
+ the section reports `model: not loaded — /text-index install`.
309
+
310
+ For an **unattended** run — a daemon, a scheduled tick, `phern -p` in CI — there is
311
+ nobody to type either:
312
+
313
+ ```yaml
314
+ - id: text-index-local
315
+ config:
316
+ preload: true
317
+ ```
318
+
319
+ That loads the model at **mount** and refuses the mount with a `MountRefusal`
320
+ sentence if it cannot, which is the cookbook's own rule: refuse at mount, not at
321
+ first use, because by then the agent is running and "refuse to start" has already
322
+ been disobeyed. Off by default, because a person at a TUI would rather the
323
+ harness start now and pay for the model when they use it.
324
+
325
+ All three call the same thing, and it **loads** the model rather than fetching
326
+ it — the `einops` failure above is exactly why. Whatever upstream says on
327
+ failure is passed through verbatim, because that sentence names the fix.
328
+
329
+ ## The skill
330
+
331
+ The package installs a `text-search` skill, so an RLM under progressive
332
+ disclosure sees one line in its catalog and can read the page when it needs
333
+ it — how to scope a search, that a score is not a confidence, that a passage can
334
+ be an argument the document goes on to reject.
335
+
336
+ Registered by the row rather than found by a directory scan, so it arrives
337
+ exactly when the tools do and leaves with them: `skills-progressive` ships an
338
+ empty `paths` on purpose, because scanning a well-known directory would make
339
+ "install a skill" mean "drop a file somewhere", and a skill is something a
340
+ distribution installs deliberately (I7).
341
+
342
+ [turbovec]: https://pypi.org/project/turbovec/