Skip to content
Runic
English
Esc
navigateopen⌘Jpreview
On this page

How it works

The signature algorithm, why matching is exact, and what the ledger actually records.

Signatures

Every decision is normalized into a stable signature before it’s used as a cache key:

export function signature(decision: Decision): string {
  const normalized = {
    intent: decision.intent,
    params: sortKeysDeep(decision.params),
  };
  return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
}

Normalization is deliberately shallow — object keys are sorted recursively so { a: 1, b: 2 } and { b: 2, a: 1 } hash identically, but nothing about intent or the param values is touched. There’s no stemming, no case-folding, no semantic canonicalization.

{ intent: "summarize_pr", params: { repo: "acme/widgets", pr: 482 } }
{ intent: "summarize_pr", params: { pr: 482, repo: "acme/widgets" } }
        │                                       │
        └──────────────── same signature ──────┘

{ intent: "summarize_pr", params: { repo: "acme/widgets", pr: 483 } }

        └── different signature (different pr) ──┘

Why exact match, not semantic match

A semantic/fuzzy cache would catch more repeats — “summarize this PR” and “give me a summary of this pull request” would both hit. It would also mean Runic has to judge whether two differently-phrased requests are close enough to share a cached answer, which is exactly the kind of non-deterministic judgment call Runic is designed to avoid making.

Exact match means a cache hit is provably the same decision, not probably the same decision. The cost is that callers have to be deliberate about what goes into params — see Quickstart.

What the ledger records

The ledger is an append-only log, not a running total that can drift:

interface LedgerEvent {
  signature: string;
  kind: "hit" | "miss";
  tokensSpent: number;
  timestamp: number;
}
  • On a miss, Runic logs the tokensSpent your code reported for producing the artifact.
  • On a hit, Runic looks up the original miss for that signature and logs the same tokensSpent value as what was saved — never an estimate, never a guess. If somehow no prior miss exists for a signature that’s being hit, it logs 0 rather than inventing a number.
summary.totalSpent = sum of all miss events
summary.totalSaved = sum of all hit events
tokensWithoutRunic = totalSpent + totalSaved   (what it would've cost with no cache at all)
savingsPercent     = totalSaved / tokensWithoutRunic

Runic never measures token cost itself. It only ever repeats back what the calling code told it via storeResult(decision, artifact, tokensSpent) — see Benchmarks for how openrouter-savings gets that number from a real API response instead of an estimate.

Storage backends

Both @runic-labs/cache and @runic-labs/ledger are built against a small storage interface, with two implementations shipped in v1:

Backend Lifetime Use case
MemoryCacheStore / MemoryLedgerStore Process lifetime only Tests, ephemeral scripts, benchmarks
FileCacheStore / FileLedgerStore Persisted to disk (.runic/ by default) A CLI process and an agent process reading the same state without a running server

Both are per-session scope in v1 — see Scope for why cross-session sharing is deliberately a later phase rather than something bolted on now.

Staleness is advisory only

Cache entries carry a stale flag, computed from a configurable window (24h by default). Runic never evicts or refuses a stale entry automatically — it’s information for the caller to act on if it wants to, not an enforcement mechanism. If your use case needs hard expiry, check entry.stale yourself and decide what to do with it.

Was this page helpful?