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
Binary file added assets/embedding-demo.tape
Binary file not shown.
6 changes: 6 additions & 0 deletions cmd/toktape/card.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ func runCard(c *cli, args []string) int {
return c.usagef("toktape: %v", err)
}

// An embedding tape has its own view, over its records (2026-10-15).
if tp.Summary.IsEmbedding() && format != outputPNG {
fmt.Fprint(c.stdout, card.EmbeddingText(tp))
return exitOK
}

if *f.explain {
explainCard(c.stderr, &tp.Summary)
}
Expand Down
9 changes: 9 additions & 0 deletions cmd/toktape/compare.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"fmt"

"github.com/midagedev/toktape/internal/card"
"github.com/midagedev/toktape/internal/compare"
"github.com/midagedev/toktape/internal/tape"
)
Expand All @@ -25,6 +26,14 @@ func runCompare(c *cli, args []string) int {
if err != nil {
return c.usagef("toktape: %v", err)
}
// Two embedding tapes compare vector by vector (2026-10-15).
if ea, eb := a.Summary.IsEmbedding(), b.Summary.IsEmbedding(); ea || eb {
if !ea || !eb {
return c.usagef("toktape compare: an embedding tape compares only with another embedding tape")
}
fmt.Fprint(c.stdout, card.EmbeddingCompare(a, b))
return exitOK
}
r, err := compare.Tapes(a, b)
if err != nil {
return c.usagef("toktape compare: %v", err)
Expand Down
151 changes: 151 additions & 0 deletions cmd/toktape/embed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package main

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"time"

"github.com/midagedev/toktape/internal/card"
"github.com/midagedev/toktape/internal/recorder"
"github.com/midagedev/toktape/internal/tape"
)

// `toktape embed` (2026-10-15): record an embedding model. Each input of a
// set is sent on its own to /v1/embeddings (or llama-server's /embedding)
// and the vectors go on the tape; `toktape card` shows them and `toktape
// compare` puts two embedding tapes side by side.

const embedUsage = `toktape embed — record an embedding model, one command

Usage:
toktape embed [flags]

Sends each input of a set, one at a time, to an embeddings endpoint and
keeps every returned vector on the tape. The summary shows per input the
dimension, norm and first values, and the cosine similarity between every
pair. ` + "`toktape play a.tape`" + ` replays it on the screen (vector strips and
a coloured cosine matrix), ` + "`--against b.tape`" + ` adds each input's cosine to
the same input in b. ` + "`toktape compare a.tape b.tape`" + ` prints the same
comparison as text: cosine and largest difference per input.

Flags:
--url URL server to attach to (default: discover)
--api NAME openai (POST /v1/embeddings, default) or llama
(POST /embedding, llama-server's own shape)
--inputs FILE JSONL, one {"id", "input"} per line, optionally with
"role" (doc|query), "topic" and, on a query, "expect"
(the doc id it should find). Default: a built-in
search set, 20 Korean/English docs in 5 topics and
6 queries
--model NAME "model" to send (openai shape; default: none)
--dimensions N "dimensions" to send (openai shape; default: none)
--encoding-format F float (default) or base64 (openai shape)
--vector-cap N keep at most N values of each vector (default 0 =
all); dim, norm and the first values are always kept
--no-prompts send inputs bare; by default a row with "role" doc or
query goes twice, with EmbeddingGemma 2's task prefix
("title: none | text: " / "task: search result |
query: ") and bare, so the replay ranks both ways
--gap D, --type-rate D, --hold D
pacing for a watchable replay: idle after each doc
(100ms), per query character (25ms, the replay types
it), after each query (1s); --no-pace for none.
Idle time is never part of a latency
--out DIR where the tape is written (default ~/.toktape/runs)
--tag TEXT, --note TEXT label the run
--host-label TEXT store TEXT as the hostname
`

// embedRun is the seam the tests use to replace the recorder.
var embedRun = recorder.RecordEmbedding

func runEmbed(ctx context.Context, c *cli, args []string) int {
fs := newFlagSet("embed")
url := fs.String("url", "", "server base URL (default: discover)")
api := fs.String("api", tape.EmbeddingAPIOpenAI, "openai or llama")
inputsFile := fs.String("inputs", "", "input set file")
model := fs.String("model", "", "model to send")
dims := fs.Int("dimensions", 0, "dimensions to send")
format := fs.String("encoding-format", "float", "float or base64")
vcap := fs.Int("vector-cap", 0, "values kept per vector")
gap := fs.Duration("gap", 100*time.Millisecond, "idle time after each doc")
typeRate := fs.Duration("type-rate", 25*time.Millisecond, "idle time per character before each query")
hold := fs.Duration("hold", time.Second, "idle time after each query")
noPace := fs.Bool("no-pace", false, "no idle time at all")
noPrompts := fs.Bool("no-prompts", false, "send every input bare, without EmbeddingGemma's task prefixes")
outDir := fs.String("out", defaultRunsDir(), "where the tape is written")
tag := fs.String("tag", "", "label the run")
note := fs.String("note", "", "label the run")
hostLabel := fs.String("host-label", "", "store this instead of the machine's hostname")
extra, err := parseArgs(fs, args)
if err != nil {
return c.badFlags("embed", embedUsage, fs, args, err)
}
if len(extra) > 0 {
return c.usagef("toktape embed: unexpected argument %q", extra[0])
}
switch {
case *api != tape.EmbeddingAPIOpenAI && *api != tape.EmbeddingAPILlama:
return c.usagef("toktape embed: --api %q: use openai or llama", *api)
case *format != "float" && *format != "base64":
return c.usagef("toktape embed: --encoding-format %q: use float or base64", *format)
case *dims < 0:
return c.usagef("toktape embed: --dimensions %d: must be positive", *dims)
case *gap < 0 || *typeRate < 0 || *hold < 0:
return c.usagef("toktape embed: --gap, --type-rate and --hold take 0 or more")
case *vcap < 0:
return c.usagef("toktape embed: --vector-cap %d: use 0 to keep whole vectors", *vcap)
}
opts := recorder.EmbeddingOptions{
BaseURL: *url, API: *api, Model: *model, Dimensions: *dims, EncodingFormat: *format,
VectorCap: *vcap, Gap: *gap, TypeRate: *typeRate, Hold: *hold, Tail: *hold + time.Second, Tag: *tag, Note: *note, Version: version,
HostLabel: recorder.HostLabel{Set: flagSet(fs, "host-label"), Text: *hostLabel},
}
if *noPace {
opts.Gap, opts.TypeRate, opts.Hold, opts.Tail = 0, 0, 0, 0
}
if !*noPrompts {
opts.Prompts = tape.EmbeddingGemmaPrompts
}
if *inputsFile != "" {
raw, err := os.ReadFile(*inputsFile)
if err != nil {
return c.usagef("toktape embed: --inputs: %v", err)
}
opts.Inputs, opts.InputsName = raw, *inputsFile
}
pinned := pinCollectors(recorder.Options{})
opts.FSRoot, opts.GPU = pinned.FSRoot, pinned.GPU
opts.Progress = func(r tape.EmbeddingRecord) {
if r.Error != "" {
fmt.Fprintf(c.stderr, " %-14s failed: %s\n", r.ID, clipLine(r.Error, 70))
return
}
fmt.Fprintf(c.stderr, " %-14s %d values %s ms\n", r.ID, r.Dim, fmtMs(float64(r.Latency().Microseconds())/1000))
}

tp, err := embedRun(ctx, opts)
if err != nil {
switch {
case errors.Is(err, recorder.ErrUnreachable):
return c.fail(failure{code: exitUnreachable, msg: fmt.Sprintf("toktape: %v", err),
hint: "name the server with --url http://host:port; llama-server needs --embeddings"})
case errors.Is(err, recorder.ErrAllStreamsFailed):
return c.fail(failure{code: exitStreams, msg: fmt.Sprintf("toktape: %v", err),
hint: "the server was reachable but embedded nothing; llama-server needs --embeddings"})
default:
return c.usagef("toktape embed: %v", err)
}
}
path := filepath.Join(*outDir, tp.Summary.ID+tape.Ext)
// Only the tape is written: the ledger and the token card refuse an
// embedding tape.
if err := tape.Write(path, tp); err != nil {
return c.usagef("toktape embed: saving the run file: %v", err)
}
fmt.Fprintf(c.stdout, "\n%s\n tape %s\n", card.EmbeddingText(tp), tildePath(path))
return exitOK
}
95 changes: 95 additions & 0 deletions cmd/toktape/embed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package main

import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/midagedev/toktape/internal/ledger"
"github.com/midagedev/toktape/internal/tape"
)

func TestEmbedRecordsCardAndCompare(t *testing.T) {
hermetic(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/embeddings" {
http.NotFound(w, r)
return
}
var req struct{ Input string }
_ = json.NewDecoder(r.Body).Decode(&req)
v := []float64{1, 0, 0}
if strings.Contains(req.Input, "b") {
v = []float64{0, 1, 0}
}
json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"embedding": v}}})
}))
t.Cleanup(srv.Close)
inputs := filepath.Join(t.TempDir(), "in.jsonl")
if err := os.WriteFile(inputs, []byte(`{"id":"one","input":"a"}`+"\n"+`{"id":"two","input":"b"}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
code, stdout, stderr := exec(t, "embed", "--url", srv.URL, "--inputs", inputs, "--out", dir, "--no-pace")
if code != exitOK {
t.Fatalf("exit %d\n%s", code, stderr)
}
for _, want := range []string{"POST /v1/embeddings", "cosine similarity", " 0 1.00 0.00", "dim 3"} {
if !strings.Contains(stdout, want) {
t.Errorf("summary lacks %q:\n%s", want, stdout)
}
}
tapes, _ := filepath.Glob(filepath.Join(dir, "*"+tape.Ext))
if len(tapes) != 1 {
t.Fatalf("tapes %v", tapes)
}
tp, err := tape.Read(tapes[0])
if err != nil || !tp.Summary.IsEmbedding() || len(tp.Embeddings) != 2 {
t.Fatalf("tape: %v", err)
}
if ledger.Holds(tp) {
t.Error("the ledger holds an embedding tape")
}
if code, out, _ := exec(t, "card", tapes[0]); code != exitOK || !strings.Contains(out, "cosine similarity") {
t.Errorf("card exit %d:\n%s", code, out)
}
code, out, _ := exec(t, "compare", tapes[0], tapes[0])
if code != exitOK || !strings.Contains(out, "1.000000 0.000000") || !strings.Contains(out, "2 inputs matched") {
t.Errorf("compare exit %d:\n%s", code, out)
}
if code, _, _ := exec(t, "compare", tapes[0], filepath.Join("..", "..", "assets", "hero.tape")); code == exitOK {
t.Error("an embedding tape compared with a token tape")
}
if code, _, _ := exec(t, "publish", tapes[0], "--yes"); code == exitOK {
t.Error("an embedding tape was published")
}
}

func TestEmbedFlagRefusals(t *testing.T) {
hermetic(t)
for name, args := range map[string][]string{
"bad api": {"embed", "--api", "x"},
"bad format": {"embed", "--encoding-format", "f16"},
"neg dims": {"embed", "--dimensions", "-1"},
"neg cap": {"embed", "--vector-cap", "-1"},
"stray arg": {"embed", "extra"},
"missing set": {"embed", "--inputs", filepath.Join(t.TempDir(), "none")},
} {
if code, _, _ := exec(t, args...); code == exitOK {
t.Errorf("%s: exit 0", name)
}
}
}

// TestEmbeddingDemoTape: the recorded llama-server tape still reads and
// shows (EmbeddingGemma 2 Q8_0, built-in search set).
func TestEmbeddingDemoTape(t *testing.T) {
code, out, _ := exec(t, "card", filepath.Join("..", "..", "assets", "embedding-demo.tape"))
if code != exitOK || !strings.Contains(out, "embeddinggemma-2") || !strings.Contains(out, "q-starter") {
t.Fatalf("exit %d:\n%s", code, out)
}
}
3 changes: 3 additions & 0 deletions cmd/toktape/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,9 @@ func usageFor(verb string) string {
if verb == "decide" {
return decideUsage
}
if verb == "embed" {
return embedUsage
}
return usageText
}

Expand Down
3 changes: 3 additions & 0 deletions cmd/toktape/ls.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ func runLs(c *cli, args []string) int {
if s.IsDecision() {
n = "decision"
}
if s.IsEmbedding() {
n = "embedding"
}
rows = append(rows, []string{
s.ID,
modelLabel(s),
Expand Down
Loading
Loading