Skip to the content.

Trajectory observability — the data plane, the similarity primitive, and the seam

fak does not ship a trajectory-analysis product. It ships the three primitives an analysis is built from, so you (or a trivial agent skill) can write your own semantic, trajectory, memory, cache, or planner optimization on top of the kernel’s defaults — without forking the kernel.

The kernel already adjudicates every tool call and fans a typed lifecycle event to any registered observer. What it lacked was an analysis-shaped view of that stream and a way to compare turns by meaning rather than exact tokens. These three leaves close that gap, each opt-in and each additive to the frozen ABI:

primitive package what it gives you
data plane internal/trajectory a typed, exportable per-turn record folded from the kernel’s event stream
reference vector similarity internal/simhash a deterministic, dependency-free embed + cosine + top-k, to find near-duplicates the lexical ranker misses
scorer seam internal/trajhook a pluggable registry of Turn → Finding scorers — attach your own analysis with no core edit

The CLI surface is fak traj (similar / cluster / score / gc / export); the reference application is the trajectory-garden skill.


1. The data plane — what a turn is

A trajectory.Turn is one analysis-shaped record of an agent action: the trace it belongs to, its order within the trace, the human-meaningful query that drove it, the tool and the kernel’s verdict, the result taint, the digest identities, the per-turn token/byte cost, and — optionally — a deterministic simhash embedding of the query. It is deliberately different from a decision-journal row: the journal is the tamper-evident audit ledger (a verdict over a digest); a Turn is the analysis surface (the query text, the cost, the cache shape, the embedding). One proves what the kernel decided; the other lets you find the bad trajectories.

The export schema is stable JSONL, one Turn per line:

{"trace_id":"sess-a","seq":1,"query":"search the knowledge base for the refund policy","tool":"search_kb","verdict":"ALLOW","token_estimate":320}
{"trace_id":"sess-a","seq":2,"query":"refund the customer's last payment","tool":"refund_payment","verdict":"DENY","reason":"POLICY_BLOCK","token_estimate":210}

How a turn is recorded without touching the ABI

A trajectory.Recorder is an abi.Emitter. The kernel already fans every lifecycle transition to registered emitters, and abi.Event carries an OPEN Fields map plus the call’s OPEN Meta map — so the producer (the gateway / agent loop) stamps the query text and per-turn cost into those open channels, and the Recorder folds them into a Turn. No ABI field is added; the recorder reads only what is already there, defaulting cleanly when a field is absent.

Recording is off by default (a benchmark should not pay to record). Turn it on with an env toggle, exactly like the audit journal:

FAK_TRAJECTORY=1         # enable the recorder
FAK_TRAJECTORY_EMBED=1   # also stamp a simhash embedding on each turn's query

Programmatically, trajectory.Enable(embedQueries) registers one Recorder and returns it; trajectory.Default() is the process-global instance a front door reads and exports.


2. The reference vector-similarity primitive

internal/simhash is the answer to “find the bad trajectories or bad queries in a useful way” — a reference vector similarity over tokens, not a learned model:

v := simhash.Embed("delete every row in the production table") // deterministic, L2-normalized
c := simhash.Cosine(v, simhash.Embed("drop all rows in prod")) // similarity in [-1, 1]

var ix simhash.Index
ix.AddText("q1", "refund the last payment", "")
matches := ix.TopK(simhash.Embed("issue a refund"), 5)          // k nearest by cosine

It is a hashing-trick sketch over word unigrams/bigrams and character 3-grams: deterministic (same text → same vector, on any platform, no RNG, no model), dependency-free, and cheap. That makes it good enough to catch near-duplicate queries and outlier trajectories on day one.

Know its ceiling. Because the features are lexical, two queries that mean the same thing in different vocabulary score low:

query A query B cosine
search the knowledge base for the refund policy look up the refund policy in the knowledge base 0.75
please refund the customer’s last payment refund the customers last payment please 0.70
delete every row in the production users table drop all records from the prod users table 0.35

Shared-vocabulary paraphrases cluster ~0.70–0.78; same-intent / different-word pairs fall to ~0.35. The default duplicate threshold (0.70) is calibrated to that reality — it catches the real redundancy without firing on distinct work. When your corpus has heavy same-intent/different-vocabulary redundancy, that is the signal to swap in real embeddings: build a simhash.Index from a sentence embedder’s []float32 (the Index machinery is model-agnostic) or register your own scorer. The reference primitive is the floor, not the ceiling — and the swap is the whole point.


3. The scorer seam — attach your own analysis

internal/trajhook is the application-layer extension point. A Scorer is a pure function from a trajectory.Turn (with the whole corpus as context) to zero or more Findings; a CorpusScorer scores the whole corpus at once. You register named scorers into a Registry and run them — the same “register a driver, don’t edit the core” discipline the kernel uses for abi.Emitter, lifted to the analysis layer where no ABI is involved at all.

reg := trajhook.NewRegistry()
reg.Register("my_regression", func(t trajectory.Turn, corpus []trajectory.Turn) []trajhook.Finding {
    // your analysis here — flag t against corpus and return findings
    return nil
})
findings := reg.Run(recorder.Turns()) // worst-first

Three reference scorers ship as worked examples (trajhook.Default()):

They are examples, not policy. fak deliberately does not ship a learned “bad-trajectory classifier” — that judgment is application-specific. What fak ships is the substrate that makes one a few lines to write.


4. The CLI — fak traj

Gardening verbs over an exported corpus. Every verb reads a corpus file; none mutates it (gc proposes, it never deletes — fak never removes a user’s trajectory data).

CORPUS=examples/trajectory/sample-corpus.jsonl

fak traj score   --corpus "$CORPUS"                          # run the reference scorers, worst-first
fak traj similar --corpus "$CORPUS" --query "issue a refund" # k most-similar past queries
fak traj cluster --corpus "$CORPUS"                          # group near-duplicate queries
fak traj gc      --corpus "$CORPUS" --json                   # propose prune candidates
fak traj export  --corpus "$CORPUS"                          # re-emit normalized JSONL

examples/trajectory/sample-corpus.jsonl is a shipped demo corpus to rehearse the verbs with no live kernel.


5. The reference application — the trajectory-garden skill

.claude/skills/trajectory-garden/ is a trivial agent skill that drives fak traj score + gc to find redundant / bad / expensive trajectories and propose prunes. It is the proof of the thesis: a relatively trivial skill can do real memory / trajectory gardening on top of the primitives — work that wasn’t possible before fak expressed the data plane, the similarity primitive, and the seam. Fork it to build your own analysis; swap the scorer and you have a different tool.


Why this altitude

The goal was never for fak to ship the semantic layer. It was to express the primitives at the kernel boundary — first-class data visibility and hooks — so the semantic, trajectory, memory, cache, and planner optimizations are something you and others write above the defaults, for a core use case or a one-off alike. The data is typed and exportable; the similarity is a deterministic reference you can replace; the seam takes your scorer with no core edit. That is the observability layer; the analysis is yours to build.