Skip to content
Open
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
30 changes: 27 additions & 3 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -5420,6 +5420,27 @@ per million out; and the bump re-explains the existing side-car once
(measured at $4.70 under `deepseek-v4.1-flash`), which `--estimate` prints
before anyone pays.

**Measured (2026-10-29) — the estimate above is replaced.** The test corpus is
Spring PetClinic, not Broadleaf (the user's decision: small enough to read
every answer). A live prompt-2 run, gpt-5.6-luna, `--max-tokens 6000`: 106
calls, 228 177 prompt + 96 356 completion tokens, $0.1343, no failure;
`--estimate` had said 66 570 out (−31%). Each call's prompt was rebuilt with
the planner (`contextPackFor` + `renderPrompts` over the run's own records;
their estimates sum to the run's, token for token) and its measured completion
tokens fitted on what it asked, reasoning tokens included since they are
billed: **466 / 534 / 527 per operation / type / module block, 121 per word,
123 per glossary entry** (r² 0.81 per call; the old figures 0.13), rounded
into `COMPLETION_TOKENS_PER_BLOCK` = 470 / 530 / 530 and 120 / 125. A word
entry's own JSON is ~50 tokens; the rest is the reasoning that goes with it.
Fitted on one half of the calls, the other half's total is predicted within
−7% and +15% (±6% without the module calls), against −29% and −33% before.
The glossary figure rests on five module calls (one half-sample fits 342): a
corpus with more modules should refit it. Per word, at the run's fitted
prices ($0.067/M in, $1.256/M out), ~$0.00015. Input, unchanged here: the
planned prompts were 15% under the measured input — 7.9% because a cold
estimate cannot quote explanations no run has written yet, 6.3% because this
model's tokenizer packs 3.76 characters per token, not 4.

**Layer 3 — readers, all over the store, none calling anything.**
`codegraph insights --word order` — every entity whose names spell it,
grouped module → type → operation, each with the identifiers it is in, its
Expand Down Expand Up @@ -5476,7 +5497,9 @@ never looks like a fact.
- **The estimate** adds 30 completion tokens per word and 40 per glossary
entry, this section's figures, until a run measures them. On the Java
fixture the plan goes from 102 786 to 114 483 prompt tokens (+11%) and
from 36 650 to 41 670 completion tokens (+14%).
from 36 650 to 41 670 completion tokens (+14%). *Measured since (the
Cost paragraph): 120 per word, 125 per entry, blocks 470 / 530 / 530; the
fixture's plan now says 52 900 completion tokens.*

**As built — the store (2026-10-27).** `INSIGHTS_DB_VERSION = 2`. Migration 1
creates `insight_word (id, entry)`, indexed on `word` and on `id`. `entry` is
Expand Down Expand Up @@ -5698,8 +5721,9 @@ Vocabulary tab's entries and the Navigate panel's, over `answerGlossary` in
answer for a record explained under prompt `"1"`; on the fake client
and live on the Java fixture; the §20.3 estimate replaced by the
Broadleaf measurement. *(In: `--terms`, the estimate's per-word
figures, the fake-client run, the prompt-1 answer. Owed: the
measurement, live runs.)*
figures, the fake-client run, the prompt-1 answer; the measurement,
from a live run on Spring PetClinic in Broadleaf's place, 2026-10-29,
§20.3 "Measured". Owed: the live run on the Java fixture.)*
- [x] Readers: `insights --word`, `--glossary`, `--polysemy`, `--concerns`;
`vocabulary --drift-insights`; `/glossary.json`; the entries in the
Vocabulary tab and in the Navigate panel as inferences.
Expand Down
8 changes: 5 additions & 3 deletions docs/insights.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,9 +351,11 @@ calls it will make, the estimated prompt tokens per level — and makes no call;
it needs no key. `--estimate` prints only the volume: calls, input and output
tokens per level, and a cost when `--price-in`/`--price-out` (USD per million
tokens) are given. Input is the rendered prompts at four characters per token;
output is one measured block average per block asked for (operation 450, type
650, module 900 tokens — averages from gpt-5.6-luna on the fixture and gson),
so a cycle call counts once per member. Repair re-asks and rate-limit retries
output is a measured figure per block asked for (operation 470, type 530,
module 530 tokens), plus 120 per word and 125 per glossary entry asked to
define — fitted on a live prompt-2 run of gpt-5.6-luna over Spring PetClinic,
reasoning tokens included (PLAN.md §20.3) — so a cycle call counts once per
member. Repair re-asks and rate-limit retries
are not counted, and records already in the side-car are not re-sent. A real
run shows that same estimate on stderr and asks for confirmation before the
first call; `--yes` skips the question, and with no terminal to ask on the run
Expand Down
13 changes: 7 additions & 6 deletions packages/cli/src/commands/explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,9 +741,10 @@ export function estimatedCost(promptTokens: number, completionTokens: number, pr

/**
* `--estimate`: the volume a run would move, before spending anything. Input
* is the rendered prompts at ~4 chars/token; output is one measured block
* average per block asked for. Repair re-asks and rate-limit retries are not
* included, and a run that reuses records sends less than a cold one.
* is the rendered prompts at ~4 chars/token; output is a measured figure per
* block, per word and per glossary entry asked for. Repair re-asks and
* rate-limit retries are not included, and a run that reuses records sends
* less than a cold one.
*/
function printEstimate(plan: RunPlan, options: ExplainOptions, io: IoSink): void {
const { estimates } = plan;
Expand All @@ -768,7 +769,7 @@ function printEstimate(plan: RunPlan, options: ExplainOptions, io: IoSink): void
...(cost === undefined ? {} : { cost }),
assumptions: {
promptTokens: "characters of the rendered prompts / 4",
completionTokens: `measured block averages: ${outputAssumption()}`,
completionTokens: `measured on a live run: ${outputAssumption()}`,
excluded: "repair re-asks, rate-limit retries",
},
}),
Expand Down Expand Up @@ -801,15 +802,15 @@ function estimateLines(plan: RunPlan, options: ExplainOptions): string[] {
} else {
lines.push(" cost: pass --price-in USD --price-out USD (per million tokens) for a figure");
}
lines.push(` input ≈ rendered prompts at 4 characters per token; output ≈ measured block averages: ${outputAssumption()}.`);
lines.push(` input ≈ rendered prompts at 4 characters per token; output ≈ measured on a live run: ${outputAssumption()}.`);
lines.push(" not included: repair re-asks, rate-limit retries. Records already in the side-car are reused, not re-sent.");
return lines;
}

/** What the output figure assumes, from the constants it is computed with. */
function outputAssumption(): string {
const { operation, type, module } = COMPLETION_TOKENS_PER_BLOCK;
return `operation ${operation}, type ${type}, module ${module} tokens per block asked for, plus an estimated ${COMPLETION_TOKENS_PER_WORD} per word and ${COMPLETION_TOKENS_PER_GLOSSARY_ENTRY} per glossary entry asked to define`;
return `operation ${operation}, type ${type}, module ${module} tokens per block asked for, plus ${COMPLETION_TOKENS_PER_WORD} per word and ${COMPLETION_TOKENS_PER_GLOSSARY_ENTRY} per glossary entry asked to define (gpt-5.6-luna, reasoning included)`;
}

/**
Expand Down
40 changes: 28 additions & 12 deletions packages/cli/test/explain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,19 @@ import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:f
import { tmpdir } from "node:os";
import { join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { loadSqlite } from "@codegraph/analyzer";
import { decodeInsights, encodeInsightsToString, insightsStorePathFor, sqliteInsightsStore, type InsightRecord, type InsightsStore } from "@codegraph/insights";
import {
COMPLETION_TOKENS_PER_BLOCK,
COMPLETION_TOKENS_PER_GLOSSARY_ENTRY,
COMPLETION_TOKENS_PER_WORD,
decodeInsights,
encodeInsightsToString,
insightsStorePathFor,
sqliteInsightsStore,
type InsightRecord,
type InsightsStore,
} from "@codegraph/insights";
import { LlmError, fakeLlmClient, type LlmClient, type LlmRequest, type Provider } from "@codegraph/llm";
import { EXIT } from "../src/exit.js";
import { captureIo } from "../src/io.js";
Expand Down Expand Up @@ -680,9 +690,9 @@ describe("explain --estimate", () => {
const report = JSON.parse(json.stdout()) as { kind: string; calls: number; promptTokens: number; completionTokens: number; cost: number; byLevel: Record<string, { completionTokens: number }> };
expect(report.kind).toBe("codegraph.explainEstimate/1");
expect(report.calls).toBe(68);
// Output = one measured block average per block asked for (a cycle call asks for several), plus
// 30 tokens per word and 40 per glossary entry its prompt asks — counted here on the prompts a run sends.
const per = { operation: 450, type: 650, module: 900 } as const;
// Output = one measured block figure per block asked for (a cycle call asks for several), plus one
// per word and one per glossary entry its prompt asks — counted here on the prompts a run sends.
const per = COMPLETION_TOKENS_PER_BLOCK;
const ran = seamWith({ OPENROUTER_API_KEY: KEY });
await explainCommand(options(["--out", OUT]), captureIo(), ran.seam);
const expected = { operation: 0, type: 0, module: 0 };
Expand All @@ -691,7 +701,7 @@ describe("explain --estimate", () => {
const unit = /^# Unit: \w+ (\S+)/u.exec(call.user)?.[1] ?? "";
const members = /Return one entry per id: (.+)\./u.exec(call.user)?.[1]?.split(", ") ?? [unit];
const words = members.reduce((n, id) => n + wordsAsked(call.user, id).size, 0);
expected[level] += per[level] * members.length + 30 * words + 40 * glossaryAsked(call.user).length;
expected[level] += per[level] * members.length + COMPLETION_TOKENS_PER_WORD * words + COMPLETION_TOKENS_PER_GLOSSARY_ENTRY * glossaryAsked(call.user).length;
}
let sum = 0;
for (const level of ["operation", "type", "module"] as const) {
Expand Down Expand Up @@ -799,12 +809,18 @@ describe("explain picks its provider from the flag or the environment", () => {

describe("explain usage errors exit 2 with nothing on stdout", () => {
it("without an API key when calls are planned", async () => {
const io = captureIo();
const code = await run(["explain", FIXTURE, "--src", SRC, "--out", OUT], io);
expect(code).toBe(EXIT.USAGE);
expect(io.stdout()).toBe("");
expect(io.stderr()).toContain("OPENROUTER_API_KEY");
expect(io.stderr()).toContain("CLOUDFLARE_API_TOKEN");
// The real seam reads the process environment: a key in the developer's shell must not make it found.
for (const name of ["OPENROUTER_API_KEY", "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_AI_GATEWAY_ID"]) vi.stubEnv(name, undefined);
try {
const io = captureIo();
const code = await run(["explain", FIXTURE, "--src", SRC, "--out", OUT], io);
expect(code).toBe(EXIT.USAGE);
expect(io.stdout()).toBe("");
expect(io.stderr()).toContain("OPENROUTER_API_KEY");
expect(io.stderr()).toContain("CLOUDFLARE_API_TOKEN");
} finally {
vi.unstubAllEnvs();
}
}, 20_000);

it("with --src and two models", () => {
Expand Down
23 changes: 12 additions & 11 deletions packages/insights/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,23 +35,24 @@ export function estimateTokens(text: string): number {
}

/**
* Completion tokens one block costs, per level — averages measured on real
* runs (gpt-5.6-luna, Java fixture and gson: ~450 per operation block, ~650
* per type block, ~900 per module block). A cycle prompt returns one block
* per member, so the estimate scales with the members asked for.
* Completion tokens one block costs, per level, before its words — fitted
* with the two figures below on a live prompt-2 run (PLAN.md §20.3:
* gpt-5.6-luna, Spring PetClinic, 106 calls, reasoning tokens included). A
* cycle prompt returns one block per member, so the estimate scales with the
* members asked for.
*/
export const COMPLETION_TOKENS_PER_BLOCK: Readonly<Record<Level, number>> = {
operation: 450,
type: 650,
module: 900,
operation: 470,
type: 530,
module: 530,
};

/**
* Completion tokens per word defined and per glossary entry (PLAN.md §20.3):
* ESTIMATES, to be replaced by a measurement on a real run under prompt 2.
* Completion tokens per word asked to define and per glossary entry asked to
* reconcile, from the same fit. The glossary figure rests on five module calls.
*/
export const COMPLETION_TOKENS_PER_WORD = 30;
export const COMPLETION_TOKENS_PER_GLOSSARY_ENTRY = 40;
export const COMPLETION_TOKENS_PER_WORD = 120;
export const COMPLETION_TOKENS_PER_GLOSSARY_ENTRY = 125;

export function estimateCompletionTokens(level: Level, blocks: number, asked: Prompt["asked"] = { words: 0, glossary: 0 }): number {
return (
Expand Down
9 changes: 5 additions & 4 deletions packages/insights/test/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { Edge, Entity } from "@codegraph/core";
import { buildWalk } from "../src/order.js";
import { planRun, retryScope, type PlanEnv, type PlanOptions } from "../src/plan.js";
import { COMPLETION_TOKENS_PER_GLOSSARY_ENTRY, COMPLETION_TOKENS_PER_WORD } from "../src/prompt.js";
import { COMPLETION_TOKENS_PER_BLOCK, COMPLETION_TOKENS_PER_GLOSSARY_ENTRY, COMPLETION_TOKENS_PER_WORD } from "../src/prompt.js";
import { memoryBook, type RecordBook } from "../src/records.js";
import { executeRun, type Completer, type CompletionRequest } from "../src/run.js";
import type { Block, FailureRecord, InsightRecord, ModuleBlock, OperationBlock, TypeBlock } from "../src/schema.js";
Expand Down Expand Up @@ -240,8 +240,9 @@ describe("planRun", () => {
expect(plan.estimates.byStatus).toEqual({ llm: 5, template: 1, reuse: 0, "skip-scope": 0, "skip-budget": 0 });
expect(plan.estimates.promptTokens).toBeGreaterThan(0);
// The type defines `total` (its field), the module `java` (its name) and reconciles `total`.
expect(plan.estimates.completionTokens).toBe(3 * 450 + (650 + COMPLETION_TOKENS_PER_WORD) + (900 + COMPLETION_TOKENS_PER_WORD + COMPLETION_TOKENS_PER_GLOSSARY_ENTRY));
expect(plan.estimates.byLevel.operation.completionTokens).toBe(3 * 450);
const { operation, type, module } = COMPLETION_TOKENS_PER_BLOCK;
expect(plan.estimates.completionTokens).toBe(3 * operation + (type + COMPLETION_TOKENS_PER_WORD) + (module + COMPLETION_TOKENS_PER_WORD + COMPLETION_TOKENS_PER_GLOSSARY_ENTRY));
expect(plan.estimates.byLevel.operation.completionTokens).toBe(3 * operation);
expect(plan.steps.find((s) => s.unit.id === m("getTotal"))?.completionTokens).toBe(0);
expect(plan.steps.find((s) => s.unit.id === m("h"))?.model).toBe("leaf-model");
expect(plan.steps.find((s) => s.unit.id === T)?.model).toBe("rollup-model");
Expand Down Expand Up @@ -392,7 +393,7 @@ describe("executeRun", () => {
const cycle = plan.steps.find((s) => s.unit.members.length === 3);
expect(cycle?.calls).toBe(1);
// One call, three blocks asked for: the output estimate scales with the members.
expect(cycle?.completionTokens).toBe(3 * 450);
expect(cycle?.completionTokens).toBe(3 * COMPLETION_TOKENS_PER_BLOCK.operation);
for (const id of [m("f"), m("g"), m("h")]) {
const rec = result.records.find((r) => r.id === id);
expect(rec?.scc).toEqual([m("f"), m("g"), m("h")]);
Expand Down
6 changes: 3 additions & 3 deletions website/content/docs/discover/meaning.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ explain plan: 77 units in 8 layers
type 17 units 17 calls ~31001 prompt tokens
module 3 units 3 calls ~5334 prompt tokens
statuses: llm 68, template 9, reuse 0, skip-scope 0, skip-budget 0
total: 68 calls, ~114483 prompt tokens in, ~41670 completion tokens out
total: 68 calls, ~114483 prompt tokens in, ~52900 completion tokens out
```

```bash
Expand All @@ -59,11 +59,11 @@ codegraph explain app.jsonl --src ~/src/app/src/main/java \
```

```text
cost at $0.1/M in, $0.6/M out: $0.0365
cost at $0.1/M in, $0.6/M out: $0.0432
```

Prices are dollars per million tokens for the model you intend to use; the
figure is a floor (retries are not in it). Three cents for the reference
figure is a floor (retries are not in it). Four cents for the reference
fixture; a few dollars for a mid-sized service with a cheap model; tens of
dollars for a very large codebase. Neither command makes a call or needs a
key, so run them before every real run.
Expand Down
7 changes: 4 additions & 3 deletions website/content/docs/explanation/explaining-bottom-up.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,10 @@ needs no API key at all, which means the plan is inspectable by someone who has
not decided to spend anything yet. An **estimate** prints the volume: calls,
input and output tokens per level, and a cost when prices per million tokens are
supplied. Input is the rendered prompts at four characters per token; output is
one measured average per block asked for — 450 tokens for an operation, 650 for a
type, 900 for a module, averaged from real runs on the fixture and on gson — so a
cycle call counts once per member. Repair re-asks and rate-limit retries are not
one measured figure per block asked for — 470 tokens for an operation, 530 for a
type or a module — plus 120 per word the prompt asks to define and 125 per
glossary entry, fitted on a live run over Spring PetClinic with the model's
reasoning counted — so a cycle call counts once per member. Repair re-asks and rate-limit retries are not
counted, and records already in the side-car are not re-sent.

Then the caps. A **maximum call count** stops planning calls after N, and what
Expand Down
12 changes: 6 additions & 6 deletions website/content/docs/how-to/explain-cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ codegraph explain model.jsonl --src fixtures/java/src \

```text
level calls input tokens output tokens
operation 48 78 148 23 550
type 17 31 001 13 730
module 3 5 334 4 390
total 68 114 483 41 670
operation 48 78 148 30 360
type 17 31 001 15 590
module 3 5 334 6 950
total 68 114 483 52 900

cost at $0.1/M in, $0.6/M out: $0.0365
input ≈ rendered prompts at 4 characters per token; output ≈ measured block averages: operation 450, type 650, module 900 tokens per block asked for, plus an estimated 30 per word and 40 per glossary entry asked to define.
cost at $0.1/M in, $0.6/M out: $0.0432
input ≈ rendered prompts at 4 characters per token; output ≈ measured on a live run: operation 470, type 530, module 530 tokens per block asked for, plus 120 per word and 125 per glossary entry asked to define (gpt-5.6-luna, reasoning included).
not included: repair re-asks, rate-limit retries. Records already in the side-car are reused, not re-sent.
```

Expand Down
12 changes: 6 additions & 6 deletions website/content/docs/tutorials/explain.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,13 @@ explain estimate: order.jsonl
models: openai/gpt-5.6-luna (operations), openai/gpt-5.6-luna (types, modules); depth 1

level calls input tokens output tokens
operation 48 78 148 23 550
type 17 31 001 13 730
module 3 5 334 4 390
total 68 114 483 41 670
operation 48 78 148 30 360
type 17 31 001 15 590
module 3 5 334 6 950
total 68 114 483 52 900

cost at $0.1/M in, $0.6/M out: $0.0365
input ≈ rendered prompts at 4 characters per token; output ≈ measured block averages: operation 450, type 650, module 900 tokens per block asked for, plus an estimated 30 per word and 40 per glossary entry asked to define.
cost at $0.1/M in, $0.6/M out: $0.0432
input ≈ rendered prompts at 4 characters per token; output ≈ measured on a live run: operation 470, type 530, module 530 tokens per block asked for, plus 120 per word and 125 per glossary entry asked to define (gpt-5.6-luna, reasoning included).
not included: repair re-asks, rate-limit retries. Records already in the side-car are reused, not re-sent.
```

Expand Down
Loading