glyphsteer 0.3.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.
Files changed (31) hide show
  1. glyphsteer-0.3.0/PKG-INFO +18 -0
  2. glyphsteer-0.3.0/README.md +130 -0
  3. glyphsteer-0.3.0/pyproject.toml +27 -0
  4. glyphsteer-0.3.0/setup.cfg +4 -0
  5. glyphsteer-0.3.0/src/glyphsteer/__init__.py +36 -0
  6. glyphsteer-0.3.0/src/glyphsteer/annotate.py +73 -0
  7. glyphsteer-0.3.0/src/glyphsteer/cli.py +69 -0
  8. glyphsteer-0.3.0/src/glyphsteer/dense.py +122 -0
  9. glyphsteer-0.3.0/src/glyphsteer/encode.py +62 -0
  10. glyphsteer-0.3.0/src/glyphsteer/eval.py +104 -0
  11. glyphsteer-0.3.0/src/glyphsteer/grammar.py +138 -0
  12. glyphsteer-0.3.0/src/glyphsteer/index.py +84 -0
  13. glyphsteer-0.3.0/src/glyphsteer/legend.py +80 -0
  14. glyphsteer-0.3.0/src/glyphsteer/steer.py +54 -0
  15. glyphsteer-0.3.0/src/glyphsteer/vocab.py +198 -0
  16. glyphsteer-0.3.0/src/glyphsteer.egg-info/PKG-INFO +18 -0
  17. glyphsteer-0.3.0/src/glyphsteer.egg-info/SOURCES.txt +29 -0
  18. glyphsteer-0.3.0/src/glyphsteer.egg-info/dependency_links.txt +1 -0
  19. glyphsteer-0.3.0/src/glyphsteer.egg-info/entry_points.txt +2 -0
  20. glyphsteer-0.3.0/src/glyphsteer.egg-info/requires.txt +17 -0
  21. glyphsteer-0.3.0/src/glyphsteer.egg-info/top_level.txt +1 -0
  22. glyphsteer-0.3.0/tests/test_dense_backend.py +51 -0
  23. glyphsteer-0.3.0/tests/test_dual_regime.py +43 -0
  24. glyphsteer-0.3.0/tests/test_encode.py +38 -0
  25. glyphsteer-0.3.0/tests/test_glyphsteer_regressions.py +112 -0
  26. glyphsteer-0.3.0/tests/test_grade.py +41 -0
  27. glyphsteer-0.3.0/tests/test_grammar.py +51 -0
  28. glyphsteer-0.3.0/tests/test_index.py +44 -0
  29. glyphsteer-0.3.0/tests/test_legend.py +50 -0
  30. glyphsteer-0.3.0/tests/test_steer.py +44 -0
  31. glyphsteer-0.3.0/tests/test_vocab.py +31 -0
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: glyphsteer
3
+ Version: 0.3.0
4
+ Summary: Dual-regime retrieval steering via compiled in-band glyph annotations
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: click
7
+ Requires-Dist: numpy
8
+ Provides-Extra: dense
9
+ Requires-Dist: fastembed; extra == "dense"
10
+ Provides-Extra: st
11
+ Requires-Dist: sentence-transformers; extra == "st"
12
+ Provides-Extra: serve
13
+ Requires-Dist: fastapi; extra == "serve"
14
+ Requires-Dist: uvicorn; extra == "serve"
15
+ Requires-Dist: fastembed; extra == "serve"
16
+ Provides-Extra: grammar
17
+ Requires-Dist: rulecatcher==0.3.0; extra == "grammar"
18
+ Requires-Dist: chaincompiler==0.3.0; extra == "grammar"
@@ -0,0 +1,130 @@
1
+ # GlyphSteer
2
+
3
+ **Dual-regime retrieval steering via compiled in-band glyph annotations.**
4
+
5
+ An LLM annotation pass writes a *controlled glyph vocabulary* onto a corpus. Those
6
+ markers **steer retrieval in two regimes at once** — lexically (a rare glyph-tag is a
7
+ maximal-IDF exact-match *facet*) and densely (an emoji carries a *learned direction* that
8
+ nudges the chunk's embedding) — and are then **stripped from the returned text**, so the
9
+ reader/generator never sees them.
10
+
11
+ > One artifact (the annotated chunk). Two indexes (BM25 + dense). Invisible at output.
12
+
13
+ This is **HoneyC pointed at the index instead of Prolog** (a glyph code is a HoneyC
14
+ chain), and the cousin of Anthropic's *Contextual Retrieval* — but with a **compiled,
15
+ queryable, faceted controlled vocabulary** in place of free-text context.
16
+
17
+ ## The sidecar split (the keystone)
18
+
19
+ ```
20
+ indexed-form = clean_text ⊕ glyph_code → embedded / FTS5-indexed (MATCHING)
21
+ returned-form = clean_text → handed to the reader (READING)
22
+ ```
23
+
24
+ The glyph lives only in the matching representation. `check_hidden` / `assert_hidden`
25
+ make the hide a hard invariant.
26
+
27
+ ## One axis, two renderings (a verified finding)
28
+
29
+ FTS5's `unicode61` tokenizer **drops emoji** (verified — it matches `0`). So each axis
30
+ carries two markers:
31
+
32
+ | | marker | regime | why |
33
+ |---|---|---|---|
34
+ | **glyph** | 😡 | dense | emoji has a learned direction, near-orthogonal to prose |
35
+ | **tag** | `gsxnegative` | lexical | rare ASCII single-token ⇒ maximal IDF, exact facet |
36
+
37
+ ## What the vocabulary guarantees
38
+
39
+ - **Matching.** A vocabulary may hold a glyph that is part of another — an emoji and its variation-selector or
40
+ skin-tone form (`⚠` and `⚠️`). Every reader — `parse`, `glyphs_in`, `code_tags`, `strip`, the hide check,
41
+ `to_honeyc` and the grammar's order check — reads left to right, at each place the longest glyph that starts there,
42
+ and never two that overlap: `⚠️` is one glyph, never `⚠` with a stray selector.
43
+ - **The hide.** `strip` removes each glyph whole and the separator with its space, and goes on until a pass removes
44
+ nothing — removing one glyph can join its neighbours into another — so the returned form holds no glyph.
45
+ - **Order.** `to_honeyc` writes the glyphs in the code's own order — a chain is order-significant.
46
+ - **The gate.** `GlyphGrammar.lint` refuses (`syntax_break`) whatever in a code is neither a vocabulary glyph nor the
47
+ separator, and names it.
48
+ - **Legends.** `merge` keeps the last author when two entries clash on a name, a glyph or the tag their names derive
49
+ (`risk` and `Risk!` both derive `gsxrisk`), and keeps every entry that clashes with nothing — a glyph a name gave
50
+ up, or an evicted entry held, is free again.
51
+ - **Search.** A query with no word in it finds nothing.
52
+
53
+ `tests/test_glyphsteer_regressions.py` pins each one.
54
+
55
+ ## Install & use
56
+
57
+ ```bash
58
+ pip install -e . # lexical regime, zero heavy deps
59
+ pip install -e '.[dense]' # adds fastembed (local ONNX, no torch) for the dense regime; '.[st]' adds sentence-transformers
60
+ ```
61
+
62
+ ```python
63
+ from glyphsteer import SENTIMENT, Chunk, RuleAnnotator, annotate_chunk, build_index, search
64
+
65
+ ann = RuleAnnotator.keyword(SENTIMENT, {
66
+ SENTIMENT.by_name("negative").glyph: ["failed", "broke", "terrible"],
67
+ SENTIMENT.by_name("positive").glyph: ["great", "excellent", "succeeded"],
68
+ })
69
+ chunks = [annotate_chunk(Chunk(c.id, c.text), ann, SENTIMENT) for c in corpus]
70
+ con = build_index(chunks)
71
+ hits = search(con, "deployment", facet=SENTIMENT.by_name("negative").glyph)
72
+ # hits[i]["text"] is CLEAN — the glyph never leaks into the output
73
+ ```
74
+
75
+ CLI: `glyphsteer vocab` · `glyphsteer annotate corpus.jsonl -k 😡=failed,broke` ·
76
+ `glyphsteer query corpus.jsonl "deployment" -f 😡`
77
+
78
+ ## Flagship use: grade a corpus, search shows grades
79
+
80
+ The vocabulary is general (`SENTIMENT` is just the demo). The built-in `GRADE` vocabulary
81
+ (🏆 excellent / ✅ good / ⚠️ fair / ❌ poor) lets an LLM grade "what's good and what isn't";
82
+ search then shows the grade and can facet to it. **Grading rides the lexical regime** — a
83
+ grade is a label you filter/display by, not a semantic direction (`experiments/grade_demo.py`):
84
+
85
+ ```
86
+ search 'database' — results WITH grades (best→worst):
87
+ [🏆 excellent ] Use parameterized queries to prevent SQL injection (OWASP A03).
88
+ [✅ good ] Connection pooling improves DB throughput; benchmark before tuning.
89
+ [⚠️ fair ] Indexes might help query speed, not totally sure when.
90
+ [❌ poor ] If the database is slow just restart the server.
91
+ facet = poor only (the audit view):
92
+ [❌ poor ] You can concatenate user input into SQL strings, usually fine.
93
+ ```
94
+
95
+ The grade glyph rides back in each hit's `code` for display; the body text stays clean.
96
+
97
+ ## The dense sidecar (containerized, no torch)
98
+
99
+ The heavy neural runtime lives in a Docker image (`serve/`): **fastembed → onnxruntime**,
100
+ model weights pre-cached, ~888MB, **no torch**. The host calls it over HTTP and stays
101
+ dependency-light (the remote client is stdlib urllib). Swapping the embedding model is one
102
+ env var — which is exactly what made the emoji-collapse finding below discoverable.
103
+
104
+ ```bash
105
+ cd serve && docker compose up --build -d # serves :8088
106
+ export GLYPHSTEER_EMBED_URL=http://localhost:8088 # host now uses the sidecar
107
+ python experiments/emoji_collapse_check.py # gate: is the model emoji-blind?
108
+ python experiments/magnitude_probe.py # Q1–Q3
109
+ ```
110
+
111
+ ## Results (measured via the sidecar, 2026-06-16)
112
+
113
+ - **Lexical Q4 (retrieval lift):** MRR **0.417 → 1.000** on the toy sentiment-faceted
114
+ corpus (`experiments/retrieval_lift.py`). Zero deps.
115
+ - **THE dense finding — emoji-direction is model-dependent:**
116
+
117
+ | model | distinct-emoji cosine | Q1 separation | verdict |
118
+ |---|---|---|---|
119
+ | `bge-small-en-v1.5` (English) | **1.000** (collapse) | −0.01…+0.08 (noise) | emoji → `[UNK]`, **blind** |
120
+ | `paraphrase-multilingual-MiniLM-L12-v2` (XLM-R) | 0.77–0.97 | **+0.28…+0.58** | carries direction ✓ |
121
+
122
+ - **With the byte-aware model:** Q1 (alignment) **passes strongly**; Q2 (per-token nudge)
123
+ is **modest** (+0.02…+0.09 Δcos); Q3 (anchor reshape) is a **controllable gain** (×2 at
124
+ weight 1) — so steering strength is a tuning knob, not a yes/no. See `implementation_plan.md`
125
+ §3.1 and `experiments/results/magnitude_probe.json`.
126
+
127
+ ## Layout
128
+ See [`implementation_plan.md`](implementation_plan.md) (canonical) and
129
+ [`.claude/rules/`](.claude/rules) for the component/flow diagrams and the discovered
130
+ development workflows.
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "glyphsteer"
7
+ version = "0.3.0"
8
+ description = "Dual-regime retrieval steering via compiled in-band glyph annotations"
9
+ requires-python = ">=3.11"
10
+ dependencies = ["click", "numpy"]
11
+
12
+ [project.optional-dependencies]
13
+ # the light local ONNX backend (no torch); the container uses the same runtime
14
+ dense = ["fastembed"]
15
+ # the heavy local backend (pulls torch) — only if you want sentence-transformers
16
+ st = ["sentence-transformers"]
17
+ # build the embedding sidecar locally (normally you just `docker compose up`)
18
+ serve = ["fastapi", "uvicorn", "fastembed"]
19
+ # syntax gating: the grammar imports ChainCompiler's lint/gate engine (`chaincompiler.bridge`) and rulecatcher's
20
+ # store inside its functions, so the extra brings both
21
+ grammar = ["rulecatcher==0.3.0", "chaincompiler==0.3.0"]
22
+
23
+ [project.scripts]
24
+ glyphsteer = "glyphsteer.cli:main"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,36 @@
1
+ """GlyphSteer — dual-regime retrieval steering via compiled in-band glyph annotations.
2
+
3
+ A compiled controlled vocabulary of glyph markers, written onto a corpus by an LLM
4
+ annotation pass, steers retrieval in BOTH regimes at once — lexical (a rare glyph is a
5
+ maximal-IDF exact-match facet) and dense (a glyph carries a learned direction that
6
+ nudges the chunk vector) — while being STRIPPED from the returned text (the sidecar
7
+ split: indexed-form ≠ returned-form). HoneyC pointed at the index instead of Prolog.
8
+
9
+ The dense layer (`dense`, `eval.magnitude_probe`) needs the optional `dense` extra;
10
+ everything else (vocab, encode, the lexical index, the steer math) is dependency-light.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ __version__ = "0.3.0"
15
+
16
+ from .annotate import Annotation, Annotator, LLMAnnotator, RuleAnnotator
17
+ from .encode import Chunk, annotate_chunk, check_hidden, indexed_form, returned_form
18
+ from .index import assert_hidden, build_index, facet_only, search
19
+ from .legend import (author, from_legend, load_legend, merge, render_legend,
20
+ save_legend, to_legend)
21
+ from .grammar import GlyphGrammar, GlyphLint
22
+ from .steer import cosine, rerank, sim_eff, unit
23
+ from .vocab import (GRADE, GRADE_RANK, SENTIMENT, SEP, Axis, Vocabulary,
24
+ grade_label, is_lexically_clean)
25
+
26
+ __all__ = [
27
+ "Axis", "Vocabulary", "SENTIMENT", "GRADE", "GRADE_RANK", "grade_label",
28
+ "SEP", "is_lexically_clean",
29
+ "Annotation", "Annotator", "RuleAnnotator", "LLMAnnotator",
30
+ "Chunk", "indexed_form", "returned_form", "annotate_chunk", "check_hidden",
31
+ "build_index", "search", "facet_only", "assert_hidden",
32
+ "to_legend", "from_legend", "author", "merge", "save_legend", "load_legend",
33
+ "render_legend", "GlyphGrammar", "GlyphLint",
34
+ "sim_eff", "cosine", "rerank", "unit",
35
+ "__version__",
36
+ ]
@@ -0,0 +1,73 @@
1
+ """The annotation pass — the LLM/judge half that writes the glyph sidecar.
2
+
3
+ An `Annotator` reads a chunk's clean text and returns the axes that apply. Two
4
+ implementations:
5
+
6
+ RuleAnnotator — deterministic predicates (no LLM). Reproducible; the CI/test pass.
7
+ LLMAnnotator — an injectable judge callable. The real research pass; the judge
8
+ returns a glyph-code string which is parsed back THROUGH the
9
+ vocabulary (unknown glyphs dropped — the vocab is the gate).
10
+
11
+ The annotation pass over a whole corpus is a ChainCompiler skillchain (SC); these
12
+ classes are the per-chunk step it runs.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Callable, Protocol
18
+
19
+ from .vocab import Vocabulary
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Annotation:
24
+ """One applied axis: which axis fired and the glyph it contributes."""
25
+ axis: str
26
+ glyph: str
27
+
28
+
29
+ class Annotator(Protocol):
30
+ def annotate(self, text: str) -> list[Annotation]: ...
31
+
32
+
33
+ class RuleAnnotator:
34
+ """Deterministic annotation by predicate. `rules: {glyph: text -> bool}`."""
35
+
36
+ def __init__(self, vocab: Vocabulary, rules: dict[str, Callable[[str], bool]]):
37
+ self.vocab = vocab
38
+ unknown = [g for g in rules if vocab.by_glyph(g) is None]
39
+ if unknown:
40
+ raise ValueError(f"rules reference glyphs not in the vocabulary: {unknown}")
41
+ self.rules = rules
42
+
43
+ def annotate(self, text: str) -> list[Annotation]:
44
+ out: list[Annotation] = []
45
+ for glyph, pred in self.rules.items():
46
+ if pred(text):
47
+ axis = self.vocab.by_glyph(glyph)
48
+ out.append(Annotation(axis.name, glyph))
49
+ return out
50
+
51
+ @classmethod
52
+ def keyword(cls, vocab: Vocabulary, keywords: dict[str, list[str]]) -> "RuleAnnotator":
53
+ """Convenience: `{glyph: [words]}` → case-insensitive substring rules."""
54
+ def make(words: list[str]) -> Callable[[str], bool]:
55
+ low = [w.lower() for w in words]
56
+ return lambda t, low=low: any(w in t.lower() for w in low)
57
+ return cls(vocab, {g: make(ws) for g, ws in keywords.items()})
58
+
59
+
60
+ class LLMAnnotator:
61
+ """Annotation by an injected judge: `judge(text, vocab) -> glyph_code_str`."""
62
+
63
+ def __init__(self, vocab: Vocabulary, judge: Callable[[str, Vocabulary], str]):
64
+ self.vocab = vocab
65
+ self.judge = judge
66
+
67
+ def annotate(self, text: str) -> list[Annotation]:
68
+ code = self.judge(text, self.vocab)
69
+ out: list[Annotation] = []
70
+ for g in self.vocab.parse(code or ""):
71
+ axis = self.vocab.by_glyph(g)
72
+ out.append(Annotation(axis.name, g))
73
+ return out
@@ -0,0 +1,69 @@
1
+ """GlyphSteer CLI — annotate / index / search / probe."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+
7
+ import click
8
+
9
+ from .annotate import RuleAnnotator
10
+ from .encode import Chunk, annotate_chunk, indexed_form, returned_form
11
+ from .index import build_index, search
12
+ from .vocab import SENTIMENT
13
+
14
+
15
+ def _load_chunks(path: str) -> list[Chunk]:
16
+ """JSONL of {id, text} → chunks."""
17
+ out = []
18
+ for line in open(path, encoding="utf-8"):
19
+ line = line.strip()
20
+ if line:
21
+ d = json.loads(line)
22
+ out.append(Chunk(d["id"], d["text"], d.get("code", ""), d.get("meta", {})))
23
+ return out
24
+
25
+
26
+ @click.group()
27
+ def main() -> None:
28
+ """GlyphSteer — dual-regime retrieval steering via glyph annotations."""
29
+
30
+
31
+ @main.command()
32
+ @click.argument("corpus")
33
+ @click.option("--keyword", "-k", multiple=True, metavar="GLYPH=word,word",
34
+ help="keyword rule, e.g. -k 😊=great,love")
35
+ def annotate(corpus: str, keyword: tuple[str, ...]) -> None:
36
+ """Annotate a JSONL corpus with the sentiment vocabulary (keyword rules)."""
37
+ kw: dict[str, list[str]] = {}
38
+ for spec in keyword:
39
+ g, _, words = spec.partition("=")
40
+ kw[g] = [w for w in words.split(",") if w]
41
+ ann = RuleAnnotator.keyword(SENTIMENT, kw) if kw else RuleAnnotator(SENTIMENT, {})
42
+ for c in _load_chunks(corpus):
43
+ ac = annotate_chunk(c, ann, SENTIMENT)
44
+ click.echo(json.dumps({"id": ac.id, "text": ac.text, "code": ac.code,
45
+ "indexed": indexed_form(ac),
46
+ "returned": returned_form(ac, SENTIMENT)},
47
+ ensure_ascii=False))
48
+
49
+
50
+ @main.command()
51
+ @click.argument("corpus")
52
+ @click.argument("query")
53
+ @click.option("--facet", "-f", default=None, help="glyph to facet on")
54
+ def query(corpus: str, query: str, facet: str | None) -> None:
55
+ """Index an (already-annotated) JSONL corpus and search it; returns CLEAN text."""
56
+ con = build_index(_load_chunks(corpus))
57
+ for h in search(con, query, facet=facet):
58
+ click.echo(json.dumps(h, ensure_ascii=False))
59
+
60
+
61
+ @main.command()
62
+ def vocab() -> None:
63
+ """Print the built-in sentiment vocabulary."""
64
+ for a in SENTIMENT.axes:
65
+ click.echo(f"{a.glyph} {a.name:10s} {a.description}")
66
+
67
+
68
+ if __name__ == "__main__": # pragma: no cover
69
+ sys.exit(main())
@@ -0,0 +1,122 @@
1
+ """The dense layer — embeddings + anchor measurement, via a pluggable backend.
2
+
3
+ The heavy neural runtime is OFF the host by default: GlyphSteer talks to the
4
+ `glyphsteer-serve` container (ONNX/fastembed, no torch) over HTTP. The host import
5
+ stays dependency-light (the remote backend is stdlib-only urllib). Local backends
6
+ exist as fallbacks.
7
+
8
+ Backend resolution (override with `backend=`):
9
+ 1. "remote" — POST to $GLYPHSTEER_EMBED_URL/embed (default if the env var is set)
10
+ 2. "fastembed" — local ONNX runtime (the `dense` extra: `fastembed`)
11
+ 3. "st" — local sentence-transformers (the `st` extra: `sentence-transformers`, heavy)
12
+
13
+ `measure_anchor(glyph)` returns the glyph's learned direction — the â_k that
14
+ `steer.sim_eff` uses to reshape the metric, and what `eval.magnitude_probe` measures.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import urllib.request
21
+ from functools import lru_cache
22
+
23
+ import numpy as np
24
+
25
+ _DEFAULT_MODEL = os.environ.get("GLYPHSTEER_MODEL", "BAAI/bge-small-en-v1.5")
26
+ _ENV_URL = "GLYPHSTEER_EMBED_URL"
27
+
28
+
29
+ def _default_backend() -> str:
30
+ if os.environ.get(_ENV_URL):
31
+ return "remote"
32
+ try:
33
+ import fastembed # noqa: F401
34
+ return "fastembed"
35
+ except ImportError:
36
+ return "st"
37
+
38
+
39
+ # ---- remote (the container; stdlib only) ------------------------------------
40
+ def _remote_embed(texts: list[str], url: str | None = None) -> np.ndarray:
41
+ base = (url or os.environ[_ENV_URL]).rstrip("/")
42
+ payload = json.dumps({"texts": list(texts)}).encode("utf-8")
43
+ req = urllib.request.Request(base + "/embed", data=payload,
44
+ headers={"Content-Type": "application/json"})
45
+ with urllib.request.urlopen(req, timeout=120) as r:
46
+ out = json.load(r)
47
+ return np.asarray(out["vectors"], dtype=np.float32)
48
+
49
+
50
+ # ---- fastembed (local ONNX) -------------------------------------------------
51
+ @lru_cache(maxsize=4)
52
+ def _fe(model: str):
53
+ try:
54
+ from fastembed import TextEmbedding
55
+ except ImportError as e: # pragma: no cover
56
+ raise ImportError("backend 'fastembed' needs fastembed (the 'dense' extra): pip install fastembed") from e
57
+ return TextEmbedding(model)
58
+
59
+
60
+ def _fastembed_embed(texts: list[str], model: str) -> np.ndarray:
61
+ return np.asarray(list(_fe(model).embed(list(texts))), dtype=np.float32)
62
+
63
+
64
+ # ---- sentence-transformers (local, heavy) -----------------------------------
65
+ @lru_cache(maxsize=4)
66
+ def _st(model: str):
67
+ try:
68
+ from sentence_transformers import SentenceTransformer
69
+ except ImportError as e: # pragma: no cover
70
+ raise ImportError("backend 'st' needs sentence-transformers (the 'st' extra): "
71
+ "pip install sentence-transformers") from e
72
+ return SentenceTransformer(model)
73
+
74
+
75
+ def _st_embed(texts: list[str], model: str) -> np.ndarray:
76
+ return np.asarray(_st(model).encode(list(texts), normalize_embeddings=False),
77
+ dtype=np.float32)
78
+
79
+
80
+ # ---- public API -------------------------------------------------------------
81
+ def embed(texts: list[str], *, backend: str | None = None,
82
+ model: str = _DEFAULT_MODEL, url: str | None = None) -> np.ndarray:
83
+ """Embed texts → (n, dim) float32 array, via the resolved backend."""
84
+ backend = backend or _default_backend()
85
+ if backend == "remote":
86
+ return _remote_embed(texts, url)
87
+ if backend == "fastembed":
88
+ return _fastembed_embed(texts, model)
89
+ if backend == "st":
90
+ return _st_embed(texts, model)
91
+ raise ValueError(f"unknown embed backend {backend!r} (remote|fastembed|st)")
92
+
93
+
94
+ def measure_anchor(glyph: str, **kw) -> np.ndarray:
95
+ """The glyph's learned direction = its embedding (the â_k for steer.sim_eff)."""
96
+ return embed([glyph], **kw)[0]
97
+
98
+
99
+ def measure_anchors(glyphs: list[str], **kw) -> np.ndarray:
100
+ """Stack of anchor directions for a list of glyphs → (k, dim)."""
101
+ return embed(list(glyphs), **kw)
102
+
103
+
104
+ def health(url: str | None = None) -> dict:
105
+ """Ping the remote embedding service."""
106
+ base = (url or os.environ[_ENV_URL]).rstrip("/")
107
+ with urllib.request.urlopen(base + "/health", timeout=10) as r:
108
+ return json.load(r)
109
+
110
+
111
+ def active_model(model: str = _DEFAULT_MODEL, url: str | None = None) -> str:
112
+ """The model name actually serving embeddings, for honest reporting.
113
+
114
+ With the remote backend the sidecar decides the model (the local `model` arg is
115
+ ignored), so we ask it; otherwise the local model name is authoritative.
116
+ """
117
+ if _default_backend() == "remote":
118
+ try:
119
+ return health(url).get("model", model)
120
+ except Exception:
121
+ return model
122
+ return model
@@ -0,0 +1,62 @@
1
+ """The sidecar split — the keystone of GlyphSteer.
2
+
3
+ A chunk has TWO representations that are deliberately NOT the same string:
4
+
5
+ indexed-form = clean_text ⊕ SEP ⊕ glyph_code → embedded / FTS5-indexed (MATCH)
6
+ returned-form = clean_text → handed to the reader (READ)
7
+
8
+ The glyph code lives ONLY in the indexed-form. It steers retrieval (lexical facet +
9
+ dense direction) and then disappears: `returned_form` is provably glyph-free. This
10
+ module owns that contract and the invariant that proves it.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+ from .vocab import SEP, Vocabulary
17
+
18
+
19
+ @dataclass
20
+ class Chunk:
21
+ """A unit of the corpus. `text` is ALWAYS clean; `code` is the glyph sidecar."""
22
+ id: str
23
+ text: str
24
+ code: str = ""
25
+ meta: dict = field(default_factory=dict)
26
+
27
+
28
+ def indexed_form(chunk: Chunk) -> str:
29
+ """What gets embedded / FTS5-indexed: clean text + the glyph code."""
30
+ return f"{chunk.text}{SEP}{chunk.code}" if chunk.code else chunk.text
31
+
32
+
33
+ def returned_form(chunk: Chunk, vocab: Vocabulary) -> str:
34
+ """What the reader/generator sees: clean text, guaranteed glyph-free."""
35
+ return vocab.strip(chunk.text)
36
+
37
+
38
+ def annotate_chunk(chunk: Chunk, annotator, vocab: Vocabulary, grammar=None) -> Chunk:
39
+ """Run the annotation pass over a chunk and attach its glyph code.
40
+
41
+ The chunk's `text` is first stripped of any stray vocabulary glyphs so the
42
+ sidecar is the ONLY source of markers (keeps the hide-invariant total).
43
+
44
+ If a `grammar` (glyphsteer.grammar.GlyphGrammar) is given, the code is gated for
45
+ syntax consistency via rulecatcher: `syntax_break` raises; `orthogonal` is repaired
46
+ to canonical order. `grammar=None` ⇒ unchanged behaviour.
47
+ """
48
+ clean = vocab.strip(chunk.text)
49
+ glyphs = [a.glyph for a in annotator.annotate(clean)]
50
+ code = vocab.code(glyphs)
51
+ if grammar is not None:
52
+ lint = grammar.lint(code)
53
+ if lint.verdict == "syntax_break":
54
+ raise ValueError(f"syntax_break in annotated code {code!r}: {lint.violations}")
55
+ if lint.verdict == "orthogonal":
56
+ code = lint.fix
57
+ return Chunk(id=chunk.id, text=clean, code=code, meta=dict(chunk.meta))
58
+
59
+
60
+ def check_hidden(chunk: Chunk, vocab: Vocabulary) -> bool:
61
+ """The hide invariant: no vocabulary glyph survives into the returned-form."""
62
+ return vocab.glyphs_in(returned_form(chunk, vocab)) == []
@@ -0,0 +1,104 @@
1
+ """The evaluation harness — produces the numbers the claim lives or dies on.
2
+
3
+ Q1 alignment : cos(glyph, aligned_word) ≫ cos(glyph, unrelated_word)?
4
+ Q2 magnitude : injecting a glyph into a neutral chunk moves its vector by Δcos toward
5
+ the axis — big enough to reorder, or a toy?
6
+ Q3 projection : does the steer.py anchor reshape amplify Q2?
7
+ Q4 retrieval : on labeled data, does annotate→steer beat the no-annotation baseline
8
+ (MRR / recall@k), lexically and densely?
9
+
10
+ The dense part (Q1–Q3) needs a dense backend — the sidecar, or the `dense` or `st` extra
11
+ (`dense.py`); the lexical arm of Q4 runs with zero deps. ASPIRATIONAL: the dense arm of Q4.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import numpy as np
16
+
17
+ from .encode import Chunk, annotate_chunk
18
+ from .index import build_index, search
19
+ from .steer import cosine, sim_eff
20
+ from .vocab import Vocabulary
21
+
22
+
23
+ # ---- retrieval metrics -------------------------------------------------------
24
+ def mrr(ranked_ids: list, relevant: set) -> float:
25
+ for i, rid in enumerate(ranked_ids, 1):
26
+ if rid in relevant:
27
+ return 1.0 / i
28
+ return 0.0
29
+
30
+
31
+ def recall_at_k(ranked_ids: list, relevant: set, k: int) -> float:
32
+ if not relevant:
33
+ return 0.0
34
+ hit = len(set(ranked_ids[:k]) & relevant)
35
+ return hit / len(relevant)
36
+
37
+
38
+ # ---- Q1/Q2/Q3 : the dense probe ---------------------------------------------
39
+ def magnitude_probe(vocab: Vocabulary, *, aligned: dict[str, str],
40
+ unrelated: dict[str, str], neutral: str,
41
+ model: str | None = None) -> dict:
42
+ """Answer Q1–Q3 for each glyph.
43
+
44
+ `aligned`/`unrelated`: {glyph: word}. `neutral`: a sentence to inject into.
45
+ Returns a per-glyph report with cos-alignment, the Δcos injection nudge, and the
46
+ projected (steered) nudge.
47
+ """
48
+ from . import dense # lazy — needs the extra
49
+ # report the model ACTUALLY serving embeddings (the remote sidecar may differ from
50
+ # any local default), so the results JSON never carries a stale/wrong model label.
51
+ model = dense.active_model() if model is None else model
52
+ rows = []
53
+ nvec = dense.embed([neutral], model=model)[0]
54
+ for a in vocab.axes:
55
+ g = a.glyph
56
+ gvec = dense.measure_anchor(g, model=model)
57
+ aw = aligned.get(g)
58
+ uw = unrelated.get(g)
59
+ cos_aligned = cos_unrelated = None
60
+ if aw:
61
+ cos_aligned = float(cosine(dense.embed([aw], model=model)[0],
62
+ gvec[None, :])[0])
63
+ if uw:
64
+ cos_unrelated = float(cosine(dense.embed([uw], model=model)[0],
65
+ gvec[None, :])[0])
66
+ injected = dense.embed([f"{neutral} {g}"], model=model)[0]
67
+ # Q2: how much did injecting g move the chunk toward g's own direction?
68
+ delta_cos = float(cosine(gvec, injected[None, :])[0]
69
+ - cosine(gvec, nvec[None, :])[0])
70
+ # Q3: the steered (projected) view of the same nudge
71
+ steered = float(sim_eff(gvec, injected[None, :], gvec[None, :], [1.0])[0]
72
+ - sim_eff(gvec, nvec[None, :], gvec[None, :], [1.0])[0])
73
+ rows.append({"glyph": g, "axis": a.name,
74
+ "cos_aligned": cos_aligned, "cos_unrelated": cos_unrelated,
75
+ "Q1_separation": (None if cos_aligned is None or cos_unrelated is None
76
+ else cos_aligned - cos_unrelated),
77
+ "Q2_delta_cos": delta_cos, "Q3_steered_delta": steered})
78
+ return {"model": model, "neutral": neutral, "glyphs": rows}
79
+
80
+
81
+ # ---- Q4 : retrieval lift -----------------------------------------------------
82
+ def retrieval_lift_lexical(corpus: list[Chunk], queries: list[dict],
83
+ annotator, vocab: Vocabulary, *, k: int = 5) -> dict:
84
+ """Lexical Q4: MRR/recall with vs without the glyph sidecar.
85
+
86
+ `queries`: [{"text": str, "facet": glyph|None, "relevant": set[id]}].
87
+ Baseline indexes clean text; steered indexes annotated chunks and faceting on.
88
+ """
89
+ base_chunks = [Chunk(c.id, c.text) for c in corpus]
90
+ ann_chunks = [annotate_chunk(c, annotator, vocab) for c in corpus]
91
+ base_con = build_index(base_chunks, vocab)
92
+ ann_con = build_index(ann_chunks, vocab)
93
+
94
+ def run(con, use_facet):
95
+ mrrs, recs = [], []
96
+ for q in queries:
97
+ facet = q.get("facet") if use_facet else None
98
+ hits = search(con, q["text"], facet=facet, vocab=vocab, limit=k)
99
+ ids = [h["id"] for h in hits]
100
+ mrrs.append(mrr(ids, q["relevant"]))
101
+ recs.append(recall_at_k(ids, q["relevant"], k))
102
+ return {"mrr": float(np.mean(mrrs)), f"recall@{k}": float(np.mean(recs))}
103
+
104
+ return {"baseline": run(base_con, False), "steered": run(ann_con, True), "k": k}