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.
- glyphsteer-0.3.0/PKG-INFO +18 -0
- glyphsteer-0.3.0/README.md +130 -0
- glyphsteer-0.3.0/pyproject.toml +27 -0
- glyphsteer-0.3.0/setup.cfg +4 -0
- glyphsteer-0.3.0/src/glyphsteer/__init__.py +36 -0
- glyphsteer-0.3.0/src/glyphsteer/annotate.py +73 -0
- glyphsteer-0.3.0/src/glyphsteer/cli.py +69 -0
- glyphsteer-0.3.0/src/glyphsteer/dense.py +122 -0
- glyphsteer-0.3.0/src/glyphsteer/encode.py +62 -0
- glyphsteer-0.3.0/src/glyphsteer/eval.py +104 -0
- glyphsteer-0.3.0/src/glyphsteer/grammar.py +138 -0
- glyphsteer-0.3.0/src/glyphsteer/index.py +84 -0
- glyphsteer-0.3.0/src/glyphsteer/legend.py +80 -0
- glyphsteer-0.3.0/src/glyphsteer/steer.py +54 -0
- glyphsteer-0.3.0/src/glyphsteer/vocab.py +198 -0
- glyphsteer-0.3.0/src/glyphsteer.egg-info/PKG-INFO +18 -0
- glyphsteer-0.3.0/src/glyphsteer.egg-info/SOURCES.txt +29 -0
- glyphsteer-0.3.0/src/glyphsteer.egg-info/dependency_links.txt +1 -0
- glyphsteer-0.3.0/src/glyphsteer.egg-info/entry_points.txt +2 -0
- glyphsteer-0.3.0/src/glyphsteer.egg-info/requires.txt +17 -0
- glyphsteer-0.3.0/src/glyphsteer.egg-info/top_level.txt +1 -0
- glyphsteer-0.3.0/tests/test_dense_backend.py +51 -0
- glyphsteer-0.3.0/tests/test_dual_regime.py +43 -0
- glyphsteer-0.3.0/tests/test_encode.py +38 -0
- glyphsteer-0.3.0/tests/test_glyphsteer_regressions.py +112 -0
- glyphsteer-0.3.0/tests/test_grade.py +41 -0
- glyphsteer-0.3.0/tests/test_grammar.py +51 -0
- glyphsteer-0.3.0/tests/test_index.py +44 -0
- glyphsteer-0.3.0/tests/test_legend.py +50 -0
- glyphsteer-0.3.0/tests/test_steer.py +44 -0
- 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,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}
|