Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ reports/

# Ad-hoc experiment harnesses (e.g. the fidelity probe) — not part of the lib
scratch_fidelity/
.codegraph
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,6 @@ the model's input — a denser but still-readable representation, not an offload
- Python 3.11+
- [uv](https://docs.astral.sh/uv/) for dependency management
- `tiktoken` (installed via `uv sync`) for token counting
- Optional: the `anthropic` extra + an API key, only if you want a real
Anthropic `count_tokens` point-check (not required; see USAGE)

## Quick Start

Expand Down Expand Up @@ -88,7 +86,7 @@ src/terse/
measure.py per-payload + cross-tokenizer token measurement
probes.py value-redundancy + cross-call-overlap ceiling probes
fluency.py does a model read the compressed form as accurately as raw JSON?
tokenize.py cl100k / o200k token counting (+ optional Anthropic)
tokenize.py cl100k / o200k token counting
report.py markdown reports (savings, per-tool, probes, tokenizer, fluency)
html_report.py charted HTML companion (inline SVG, no JS/CDN) for measure/verify
cli.py entrypoint: gate / capture / measure / probe / validate / compress / proxy / fluency
Expand Down
17 changes: 7 additions & 10 deletions TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,7 @@ raw tool output (JSON text)
estimators for whether higher-ceiling levers (dictionary, cross-call diffing) are
worth building. They measure, they do not compress.
- **`tokenize.py`** — `count(text, encoding)` over named tiktoken vocabs (cl100k,
o200k), `encode_cl100k` (token ids for probes), `count_anthropic` (optional, needs
a key).
o200k) and `encode_cl100k` (token ids for probes).
- **`report.py`** — markdown renderers: `build_report` (savings by shape + per-tool +
tier attribution + coverage + gate banner), `build_probe_report`,
`build_tokenizer_report`.
Expand Down Expand Up @@ -106,12 +105,9 @@ raw tool output (JSON text)

- **tiktoken (local)** — token counting under `cl100k_base` and `o200k_base`. No
network at runtime after the one-time vocab download. Used for measurement and for
the dictionary coder's cost-aware aliasing threshold.
- **Anthropic `count_tokens` (optional)** — `count_anthropic` calls the Messages
`count_tokens` endpoint if the `anthropic` extra is installed and a key is present.
There is **no public local tokenizer for Claude 3+**, so this is the only way to
get true Claude token counts; it is off by default. Sending a payload to this
endpoint transmits it to Anthropic — run it on public data only.
the dictionary coder's cost-aware aliasing threshold. There is **no public local
tokenizer for Claude 3+**, so cl100k is an estimate; cross-tokenizer invariance
(cl100k vs o200k) is the keyless robustness check rather than a true Claude count.
- No other external services. terse does not call any tool APIs itself; it compresses
output that is piped or passed to it.

Expand Down Expand Up @@ -152,8 +148,9 @@ full tiers, kb drops dictionary, `*.rate_limit` skipped).

### Environment

- `ANTHROPIC_API_KEY` — only read by `count_anthropic` / `terse measure --anthropic`.
Absent by default; everything else runs without it.
- `TERSE_FLUENCY_BASE_URL` / `TERSE_FLUENCY_API_KEY` / `TERSE_FLUENCY_MODELS` — the
OpenAI-compatible endpoint, key, and model list for the live fluency eval (broker
pool or a loopback gateway). Absent by default; the pure core runs without them.

## Deployment

Expand Down
10 changes: 5 additions & 5 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ uv run terse fluency --corpus corpus-stress
# 2b. with models (one OpenAI-compatible endpoint, e.g. OpenRouter):
TERSE_FLUENCY_BASE_URL=https://openrouter.ai/api/v1 \
TERSE_FLUENCY_API_KEY=sk-... \
TERSE_FLUENCY_MODELS=google/gemini-2.5-flash,anthropic/claude-haiku-4.5 \
TERSE_FLUENCY_MODELS=google/gemini-2.5-flash,deepseek/deepseek-chat \
uv run terse fluency --corpus corpus-stress

# 2c. tighten the verdict: repeat each question N times for a confidence interval
Expand Down Expand Up @@ -447,7 +447,7 @@ it never has to "fetch" anything that was removed.
Because it was measured to only pay off on some. Compressing already-tidy output wastes
effort, so the policy turns it off there. You control this in the policy file.

**Do I need an Anthropic or OpenAI key?**
No. Everything runs locally. A key is only needed for one optional command that
double-checks token counts against Anthropic directly, and you never need it for normal
use.
**Do I need an API key?**
No. Everything runs locally. A key is only needed for the optional live fluency eval
(`fluency --diff`/`--drop-eval`), which calls any OpenAI-compatible endpoint you point
it at (broker pool or a loopback gateway). You never need it for normal use.
5 changes: 0 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,6 @@ dependencies = [
"tiktoken>=0.7",
]

[project.optional-dependencies]
# Anthropic count_tokens = ground-truth token count for the real consumer.
# Optional so the spike runs without an API key / network; tokenize.py imports it lazily.
anthropic = ["anthropic"]

[project.scripts]
terse = "terse.cli:main"

Expand Down
2 changes: 1 addition & 1 deletion src/terse/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""terse — lossless-first compression layer for AI-agent tool outputs.

Phase-0 spike. The measurable spine lives in `transforms` (lossless Tier-0:
minify + tabularize) and `tokenize` (cl100k + Anthropic count). Everything else
minify + tabularize) and `tokenize` (cl100k + o200k counts). Everything else
(`capture`, `probes`, `report`, `cli`) is stubbed to the plan at
~/.claude/plans/terse-lossless-tool-output-compression.md and filled in as the
spike runs.
Expand Down
40 changes: 15 additions & 25 deletions src/terse/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Subcommands:
gate <file|-> run the lossless round-trip gate on a JSON payload
capture --tool N <file|-> persist a tool output to corpus/ + bucket by shape
measure [--anthropic] token delta per tier per shape bucket over the corpus
measure token delta per tier per shape bucket over the corpus
probe value-redundancy + cross-call-overlap ceiling probes
validate cross-tokenizer invariance (cl100k vs o200k)
compress --tool N compress one tool output through a policy (the shell)
Expand Down Expand Up @@ -103,7 +103,7 @@ def _cmd_measure(args: argparse.Namespace) -> int:
if not envelopes:
print(f"no payloads in {args.corpus}/ — capture some first (`terse capture`).")
return 1
rows = measure_corpus(envelopes, use_anthropic=args.anthropic)
rows = measure_corpus(envelopes)
cov = coverage(envelopes)
report = build_report(rows, cov)
out = Path(args.out)
Expand Down Expand Up @@ -348,14 +348,15 @@ def _cmd_probe_cross_server(args: argparse.Namespace, envelopes: list[dict]) ->
return 0


def _build_answerers(args: argparse.Namespace, make_openai, make_anthropic, *,
warn_label: str = "anthropic") -> dict:
def _build_answerers(args: argparse.Namespace, make_openai) -> dict:
"""Assemble named answerers from env + flags. Empty means keyless (pack) mode.

Shared by plain `fluency` (`make_openai=fluency.openai_answerer`) and
`fluency --drop-eval` (`make_openai` bound to `dropeval.openai_tool_answerer` +
the retrieve tool) so the two eval modes stay configured identically — only the
answerer FACTORY differs, never the env/flag precedence."""
answerer FACTORY differs, never the env/flag precedence. Every model is reached over
the OpenAI-compatible path (the broker pool or a loopback gateway) — there is no
other model backend."""
import os

# Flags win over env so a credential-injecting launcher (e.g. secret_inject_env,
Expand All @@ -367,11 +368,6 @@ def _build_answerers(args: argparse.Namespace, make_openai, make_anthropic, *,
if base and key and models:
for m in (x.strip() for x in models.split(",") if x.strip()):
answerers[m] = make_openai(base, key, m)
if args.anthropic:
try:
answerers[f"anthropic:{args.anthropic_model}"] = make_anthropic(args.anthropic_model)
except Exception as e: # missing extra/key — fall back, don't crash the run
print(f"[warn] {warn_label} answerer unavailable: {e}", file=sys.stderr)
return answerers


Expand Down Expand Up @@ -411,12 +407,10 @@ def _cmd_fluency(args: argparse.Namespace) -> int:
args,
lambda base, key, m: dropeval.openai_tool_answerer(base, key, m,
tools=[RETRIEVE_TOOL_DEF]),
lambda model: dropeval.anthropic_tool_answerer(model, tools=[RETRIEVE_TOOL_DEF]),
warn_label="anthropic tool",
)
if not answerers:
print("`fluency --drop-eval` needs a configured model: set TERSE_FLUENCY_BASE_URL/"
"_API_KEY/_MODELS or pass --anthropic.")
"_API_KEY/_MODELS.")
return 1
results = dropeval.run_drop_fluency(envelopes, pol.select, answerers, trials=args.trials)
_write_report(build_dropeval_report(results), args.out)
Expand All @@ -427,10 +421,10 @@ def _cmd_fluency(args: argparse.Namespace) -> int:
# Diff mode: does a model read a cross-call DIFF as well as the full result? Needs a
# live model (it measures comprehension of a form, not ground-truth math).
if args.diff:
answerers = _build_answerers(args, fluency.openai_answerer, fluency.anthropic_answerer)
answerers = _build_answerers(args, fluency.openai_answerer)
if not answerers:
print("`fluency --diff` needs a configured model: set TERSE_FLUENCY_BASE_URL/"
"_API_KEY/_MODELS or pass --anthropic.")
"_API_KEY/_MODELS.")
return 1
results = fluency.run_diff_fluency(envelopes, answerers, trials=args.trials)
_write_report(build_diff_report(results), args.out)
Expand All @@ -442,10 +436,10 @@ def _cmd_fluency(args: argparse.Namespace) -> int:
# text + text-diff) as from the full current text? The text-payload analogue of
# --diff above (text_diff.py, Tier 0.7 — non-JSON tool output).
if args.text_diff_eval:
answerers = _build_answerers(args, fluency.openai_answerer, fluency.anthropic_answerer)
answerers = _build_answerers(args, fluency.openai_answerer)
if not answerers:
print("`fluency --text-diff-eval` needs a configured model: set "
"TERSE_FLUENCY_BASE_URL/_API_KEY/_MODELS or pass --anthropic.")
"TERSE_FLUENCY_BASE_URL/_API_KEY/_MODELS.")
return 1
results = fluency.run_text_diff_fluency(envelopes, answerers, trials=args.trials)
_write_report(build_text_diff_report(results), args.out)
Expand All @@ -464,7 +458,7 @@ def _cmd_fluency(args: argparse.Namespace) -> int:
print("\n" + build_terminal_fluency_report(results))
return 0

answerers = _build_answerers(args, fluency.openai_answerer, fluency.anthropic_answerer)
answerers = _build_answerers(args, fluency.openai_answerer)
if not answerers:
# Keyless default: write the eval pack and explain how to drive it. The pack
# embeds each payload's RAW captured text (fluency.build_pack) — the same
Expand All @@ -477,8 +471,8 @@ def _cmd_fluency(args: argparse.Namespace) -> int:
nq = sum(len(p["questions"]) for p in pack["payloads"])
print(f"no model configured — wrote {nq} questions over {len(pack['payloads'])} "
f"record-shaped payloads to {out}.")
print("To run a model: set TERSE_FLUENCY_BASE_URL/_API_KEY/_MODELS (broker pool) "
"or pass --anthropic, then re-run.")
print("To run a model: set TERSE_FLUENCY_BASE_URL/_API_KEY/_MODELS (broker pool), "
"then re-run.")
print(f"Or drive the pack by hand and score it: `terse fluency --responses <file> "
f"--pack {out}`.")
return 0
Expand Down Expand Up @@ -679,7 +673,7 @@ def _cmd_verify(args: argparse.Namespace) -> int:
label = ("bundled deterministic sample — synthetic; capture real traffic with "
"`terse capture` for your own numbers")

rows = measure_corpus(envelopes, use_anthropic=False)
rows = measure_corpus(envelopes)
cov = coverage(envelopes)
report = build_verify_header(label, len(envelopes)) + build_report(rows, cov)
_write_report(report, args.out)
Expand Down Expand Up @@ -742,7 +736,6 @@ def main(argv: list[str] | None = None) -> int:
m = sub.add_parser("measure", help="token delta per tier per shape bucket over the corpus")
m.add_argument("--corpus", default=DEFAULT_CORPUS)
m.add_argument("--out", default=DEFAULT_REPORT)
m.add_argument("--anthropic", action="store_true", help="also count with Anthropic (network)")
m.add_argument("--html", action="store_true",
help="also write a charted HTML report next to --out (inline SVG, no JS/CDN)")
m.add_argument("--bars", action="store_true",
Expand Down Expand Up @@ -825,9 +818,6 @@ def main(argv: list[str] | None = None) -> int:
f.add_argument("--models", help="comma-separated model ids (else $TERSE_FLUENCY_MODELS)")
f.add_argument("--api-key-env", default="TERSE_FLUENCY_API_KEY",
help="env var holding the API key (default TERSE_FLUENCY_API_KEY)")
f.add_argument("--anthropic", action="store_true",
help="also test the real consumer (needs the anthropic extra + key)")
f.add_argument("--anthropic-model", default="claude-opus-4-8")
f.add_argument("--bars", action="store_true",
help="also print a terminal forest plot (accuracy + 95%% CI per model, "
"ANSI if a tty)")
Expand Down
71 changes: 2 additions & 69 deletions src/terse/dropeval.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,8 @@ def run_drop_fluency(envelopes: list[dict], rule_for: Callable[[str], Any],


# --------------------------------------------------------------------------- #
# Tool-capable live backends — zero new dependencies (mirrors fluency.py's urllib/
# anthropic-extra pattern exactly, just carrying a `tools` param + parsing tool_calls).
# Tool-capable live backend — zero new dependencies (mirrors fluency.openai_answerer's
# urllib pattern, just carrying a `tools` param + parsing tool_calls).
# --------------------------------------------------------------------------- #
def _to_openai_tool(tool_def: dict) -> dict:
"""RETRIEVE_TOOL_DEF's MCP `inputSchema` shape -> OpenAI function-calling `parameters`."""
Expand Down Expand Up @@ -385,70 +385,3 @@ def ask(messages: list[dict]) -> Turn:
return Turn(text=msg.get("content") or "", tool_calls=calls)

return ask


def _to_anthropic_tool(tool_def: dict) -> dict:
return {
"name": tool_def["name"],
"description": tool_def.get("description", ""),
"input_schema": tool_def.get("inputSchema", {}),
}


def _to_anthropic_messages(messages: list[dict]) -> tuple[str, list[dict]]:
"""Convert the neutral OpenAI-style running conversation into Anthropic's system
string + tool_use/tool_result content-block message shape."""
system = ""
out: list[dict] = []
for m in messages:
role = m["role"]
if role == "system":
system = m.get("content", "")
elif role == "user":
out.append({"role": "user", "content": m.get("content", "")})
elif role == "assistant":
content: list[dict] = []
if m.get("content"):
content.append({"type": "text", "text": m["content"]})
for tc in m.get("tool_calls", []):
fn = tc.get("function", {})
try:
arguments = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
arguments = {}
content.append({"type": "tool_use", "id": tc.get("id", ""),
"name": fn.get("name", ""), "input": arguments})
out.append({"role": "assistant", "content": content})
elif role == "tool":
out.append({"role": "user", "content": [{
"type": "tool_result", "tool_use_id": m.get("tool_call_id", ""),
"content": m.get("content", ""),
}]})
return system, out


def anthropic_tool_answerer(model: str, tools: list[dict],
max_tokens: int = 1024) -> ToolAnswerer:
"""The real-consumer tool-calling answerer via the optional `anthropic` extra. Raises
at construction if the extra/key is absent, so the CLI can warn and fall back cleanly
— mirrors fluency.anthropic_answerer's contract exactly."""
import anthropic

client = anthropic.Anthropic()
anthropic_tools = [_to_anthropic_tool(t) for t in tools]

def ask(messages: list[dict]) -> Turn:
system, anth_messages = _to_anthropic_messages(messages)
kwargs: dict[str, Any] = {
"model": model, "max_tokens": max_tokens, "messages": anth_messages,
"tools": anthropic_tools,
}
if system:
kwargs["system"] = system
resp = client.messages.create(**kwargs)
text = "".join(b.text for b in resp.content if getattr(b, "type", None) == "text")
calls = [ToolCall(call_id=b.id, name=b.name, arguments=b.input)
for b in resp.content if getattr(b, "type", None) == "tool_use"]
return Turn(text=text, tool_calls=calls)

return ask
26 changes: 3 additions & 23 deletions src/terse/fluency.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@
cross-tokenizer divergence rather than averaging it away).

The answerer is a pluggable `(system, user) -> reply` callable, so the pure core
(question generation + scoring) runs offline with no network or key. Live backends
(`openai_answerer` over stdlib urllib for the broker pool; `anthropic_answerer` via
the existing optional extra for the real consumer) add zero new dependencies.
(question generation + scoring) runs offline with no network or key. The live backend
(`openai_answerer` over stdlib urllib) reaches any OpenAI-compatible endpoint — the
broker pool or a loopback gateway — and adds zero new dependencies.
"""

from __future__ import annotations
Expand Down Expand Up @@ -673,23 +673,3 @@ def ask(system: str, user: str) -> str:
return data["choices"][0]["message"]["content"] or ""

return ask


def anthropic_answerer(model: str = "claude-opus-4-8", max_tokens: int = 1024) -> Answerer:
"""The real-consumer answerer via the optional `anthropic` extra. Raises at
construction if the extra/key is absent, so the CLI can fall back cleanly."""
import anthropic

client = anthropic.Anthropic()

def ask(system: str, user: str) -> str:
kwargs: dict[str, Any] = {
"model": model, "max_tokens": max_tokens,
"messages": [{"role": "user", "content": user}],
}
if system:
kwargs["system"] = system
resp = client.messages.create(**kwargs)
return "".join(b.text for b in resp.content if getattr(b, "type", None) == "text")

return ask
Loading
Loading