|
|
@@ -0,0 +1,528 @@
|
|
|
+/**
|
|
|
+ * Per-file allocation diagnostic for `codegraph_explore` (CG-4).
|
|
|
+ *
|
|
|
+ * The explore response is a fixed byte envelope (`budget.maxOutputChars`, hard-
|
|
|
+ * capped at 25K so the host never externalizes the result). WHICH files fill it,
|
|
|
+ * and in what proportion, is decided by a long chain of gates, tiers and caps
|
|
|
+ * spread across `handleExplore`. That chain is currently unobservable: you can
|
|
|
+ * read the output and guess, but you cannot say "this file took 16% of the
|
|
|
+ * envelope and that one took 21%" without hand-counting.
|
|
|
+ *
|
|
|
+ * This module is the instrument. Enabled by `CODEGRAPH_EXPLORE_DEBUG`, it
|
|
|
+ * records, for one explore call:
|
|
|
+ * - per candidate file: relevance score, graph (RWR) mass, distinct query-term
|
|
|
+ * hits, ranking flags, render mode, bytes of source actually emitted, that
|
|
|
+ * file's share of the final envelope, whether it was clipped, whether it
|
|
|
+ * carries a flow-spine symbol — and for the ones that didn't render, why;
|
|
|
+ * - totals: envelope vs `maxOutputChars` vs the hard ceiling, source bytes vs
|
|
|
+ * meta-text overhead, files considered at each filter stage, and the score
|
|
|
+ * floor / relevance-gate thresholds that were applied.
|
|
|
+ *
|
|
|
+ * HARD CONSTRAINT — this ships in the product binary: when the env var is unset
|
|
|
+ * the diagnostic must not exist. `start()` returns `null`, every call site is a
|
|
|
+ * `diag?.` no-op, and the agent-facing response is byte-identical. The
|
|
|
+ * diagnostic never mutates render state, and every method is wrapped so a bug in
|
|
|
+ * here can never fail an explore call.
|
|
|
+ *
|
|
|
+ * Sinks (value of `CODEGRAPH_EXPLORE_DEBUG`):
|
|
|
+ * `1` / `true` / `on` / `yes` / `stderr` → human-readable table on stderr
|
|
|
+ * `json` → one JSON object on stderr
|
|
|
+ * anything else → treated as a path; one JSON object
|
|
|
+ * per line appended (JSONL sidecar)
|
|
|
+ */
|
|
|
+
|
|
|
+import { appendFileSync } from 'fs';
|
|
|
+
|
|
|
+/** How a file's source was rendered into the response. */
|
|
|
+export type ExploreRenderMode =
|
|
|
+ | 'whole' // whole-file window
|
|
|
+ | 'clusters' // ranked contiguous clusters
|
|
|
+ | 'focused' // per-symbol view, named/spine bodies full
|
|
|
+ | 'skeleton' // per-symbol view, signatures only
|
|
|
+ | 'stale-omitted' // drifted on disk; source deliberately withheld
|
|
|
+ | 'dropped'; // rendered into `lines` but cut by the final hard ceiling
|
|
|
+
|
|
|
+/** Why a ranked candidate never reached the output. */
|
|
|
+export type ExploreSkipReason =
|
|
|
+ | 'max-files' // maxFiles reached before this file
|
|
|
+ | 'budget-90pct' // incidental file past the 90%-of-budget soft stop
|
|
|
+ | 'budget-whole-file' // incidental whole-file render wouldn't fit
|
|
|
+ | 'budget-clusters' // incidental cluster render wouldn't fit
|
|
|
+ | 'unreadable' // outside root, missing, or read error
|
|
|
+ | 'no-ranges'; // no renderable line ranges in this file
|
|
|
+
|
|
|
+/** Ranking inputs for one candidate file, captured before the render loop. */
|
|
|
+export interface ExploreCandidateMeta {
|
|
|
+ rank: number;
|
|
|
+ score: number;
|
|
|
+ graphScore: number;
|
|
|
+ termHits: number;
|
|
|
+ nodes: number;
|
|
|
+ named: boolean;
|
|
|
+ central: boolean;
|
|
|
+ entry: boolean;
|
|
|
+ spine: boolean;
|
|
|
+ lowValue: boolean;
|
|
|
+ generated: boolean;
|
|
|
+}
|
|
|
+
|
|
|
+interface FileRecord extends ExploreCandidateMeta {
|
|
|
+ path: string;
|
|
|
+ render?: ExploreRenderMode;
|
|
|
+ /** Source chars the render loop handed to `lines` (pre-final-truncation). */
|
|
|
+ emittedChars: number;
|
|
|
+ /** Source chars present in the FINAL text — authoritative, truncation-aware. */
|
|
|
+ finalChars: number;
|
|
|
+ /** Share of the DELIVERED envelope, as a fraction (0–1). */
|
|
|
+ share: number;
|
|
|
+ /** Share of what the render loop ALLOCATED, before the hard-ceiling cut. */
|
|
|
+ allocatedShare: number;
|
|
|
+ clipped: boolean;
|
|
|
+ skipped?: ExploreSkipReason;
|
|
|
+}
|
|
|
+
|
|
|
+interface StageCounts {
|
|
|
+ /** Files with at least one gathered node. */
|
|
|
+ grouped: number;
|
|
|
+ /** Survived the `group.score >= scoreFloor` filter. */
|
|
|
+ pastScoreFloor: number;
|
|
|
+ /** Survived the test/spec/icon/i18n hard-exclude. */
|
|
|
+ pastLowValueFilter: number;
|
|
|
+ /** Survived the graph-relevance gate. */
|
|
|
+ pastRelevanceGate: number;
|
|
|
+}
|
|
|
+
|
|
|
+/** Budget fields the diagnostic reports. Structural, to avoid a cyclic import. */
|
|
|
+interface BudgetShape {
|
|
|
+ maxOutputChars: number;
|
|
|
+ maxCharsPerFile: number;
|
|
|
+ defaultMaxFiles: number;
|
|
|
+}
|
|
|
+
|
|
|
+/** One file's line in the report. Also the JSONL sidecar's per-file shape. */
|
|
|
+export interface ExploreDiagnosticFile extends ExploreCandidateMeta {
|
|
|
+ path: string;
|
|
|
+ render: ExploreRenderMode | null;
|
|
|
+ skipped: ExploreSkipReason | null;
|
|
|
+ clipped: boolean;
|
|
|
+ emittedChars: number;
|
|
|
+ finalChars: number;
|
|
|
+ share: number;
|
|
|
+ allocatedShare: number;
|
|
|
+}
|
|
|
+
|
|
|
+/** The full report — one per explore call, JSON-serialized to the sink. */
|
|
|
+export interface ExploreDiagnosticReport {
|
|
|
+ tool: 'codegraph_explore';
|
|
|
+ query: string;
|
|
|
+ projectRoot: string;
|
|
|
+ indexedFileCount: number;
|
|
|
+ note?: string;
|
|
|
+ budget: {
|
|
|
+ maxOutputChars: number;
|
|
|
+ maxCharsPerFile: number;
|
|
|
+ maxFiles: number;
|
|
|
+ hardCeiling: number;
|
|
|
+ };
|
|
|
+ envelope: {
|
|
|
+ /** Chars actually returned to the agent (post-truncation). */
|
|
|
+ chars: number;
|
|
|
+ /** Chars the render loop produced, BEFORE the hard-ceiling cut. */
|
|
|
+ allocatedChars: number;
|
|
|
+ overBudget: boolean;
|
|
|
+ truncated: boolean;
|
|
|
+ sourceChars: number;
|
|
|
+ sourceShare: number;
|
|
|
+ metaChars: number;
|
|
|
+ metaShare: number;
|
|
|
+ };
|
|
|
+ selection: {
|
|
|
+ scoreFloor: number;
|
|
|
+ maxGraph: number;
|
|
|
+ graphGateThreshold: number;
|
|
|
+ graphGateApplied: boolean;
|
|
|
+ filesGrouped: number;
|
|
|
+ filesPastScoreFloor: number;
|
|
|
+ filesPastLowValueFilter: number;
|
|
|
+ filesRanked: number;
|
|
|
+ filesRenderedByLoop: number;
|
|
|
+ filesInFinalOutput: number;
|
|
|
+ };
|
|
|
+ files: ExploreDiagnosticFile[];
|
|
|
+}
|
|
|
+
|
|
|
+type Sink =
|
|
|
+ | { kind: 'stderr'; json: boolean }
|
|
|
+ | { kind: 'file'; path: string };
|
|
|
+
|
|
|
+const OFF = new Set(['', '0', 'false', 'off', 'no']);
|
|
|
+const STDERR_TABLE = new Set(['1', 'true', 'on', 'yes', 'stderr']);
|
|
|
+
|
|
|
+/**
|
|
|
+ * Resolve the sink from the environment. `null` means the diagnostic is off —
|
|
|
+ * read per call (not memoized) so a test can toggle it between invocations.
|
|
|
+ */
|
|
|
+function resolveSink(): Sink | null {
|
|
|
+ const raw = process.env.CODEGRAPH_EXPLORE_DEBUG;
|
|
|
+ if (raw === undefined) return null;
|
|
|
+ const value = raw.trim();
|
|
|
+ const lower = value.toLowerCase();
|
|
|
+ if (OFF.has(lower)) return null;
|
|
|
+ if (STDERR_TABLE.has(lower)) return { kind: 'stderr', json: false };
|
|
|
+ if (lower === 'json') return { kind: 'stderr', json: true };
|
|
|
+ return { kind: 'file', path: value };
|
|
|
+}
|
|
|
+
|
|
|
+const num = (n: number) => Math.round(n).toLocaleString('en-US');
|
|
|
+const pct = (f: number) => `${(f * 100).toFixed(1)}%`;
|
|
|
+
|
|
|
+export class ExploreDiagnostics {
|
|
|
+ private readonly files = new Map<string, FileRecord>();
|
|
|
+ private readonly stages: StageCounts = {
|
|
|
+ grouped: 0, pastScoreFloor: 0, pastLowValueFilter: 0, pastRelevanceGate: 0,
|
|
|
+ };
|
|
|
+ private scoreFloor = 0;
|
|
|
+ private maxGraph = 0;
|
|
|
+ private graphGateThreshold = 0;
|
|
|
+ private graphGateApplied = false;
|
|
|
+ private note = '';
|
|
|
+
|
|
|
+ private constructor(
|
|
|
+ private readonly sink: Sink,
|
|
|
+ private readonly query: string,
|
|
|
+ private readonly projectRoot: string,
|
|
|
+ private readonly budget: BudgetShape,
|
|
|
+ private readonly maxFiles: number,
|
|
|
+ private readonly indexedFileCount: number,
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Returns `null` when `CODEGRAPH_EXPLORE_DEBUG` is unset/off — the whole
|
|
|
+ * instrument then costs one env read per explore call and nothing else.
|
|
|
+ */
|
|
|
+ static start(
|
|
|
+ query: string,
|
|
|
+ projectRoot: string,
|
|
|
+ budget: BudgetShape,
|
|
|
+ maxFiles: number,
|
|
|
+ indexedFileCount: number,
|
|
|
+ ): ExploreDiagnostics | null {
|
|
|
+ try {
|
|
|
+ const sink = resolveSink();
|
|
|
+ if (!sink) return null;
|
|
|
+ return new ExploreDiagnostics(sink, query, projectRoot, budget, maxFiles, indexedFileCount);
|
|
|
+ } catch {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Candidate count after the initial `group.score >= floor` filter. */
|
|
|
+ setScoreFloor(floor: number, grouped: number, kept: number): void {
|
|
|
+ this.scoreFloor = floor;
|
|
|
+ this.stages.grouped = grouped;
|
|
|
+ this.stages.pastScoreFloor = kept;
|
|
|
+ this.stages.pastLowValueFilter = kept;
|
|
|
+ this.stages.pastRelevanceGate = kept;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Candidate count after the test/spec/icon/i18n hard-exclude. */
|
|
|
+ setLowValueFiltered(kept: number): void {
|
|
|
+ this.stages.pastLowValueFilter = kept;
|
|
|
+ this.stages.pastRelevanceGate = kept;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Graph-relevance gate: threshold, whether it actually pruned, what survived. */
|
|
|
+ setRelevanceGate(maxGraph: number, threshold: number, applied: boolean, kept: number): void {
|
|
|
+ this.maxGraph = maxGraph;
|
|
|
+ this.graphGateThreshold = threshold;
|
|
|
+ this.graphGateApplied = applied;
|
|
|
+ this.stages.pastRelevanceGate = kept;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Record one ranked candidate's scoring inputs, in final sort order. */
|
|
|
+ noteCandidate(path: string, meta: ExploreCandidateMeta): void {
|
|
|
+ this.files.set(path, {
|
|
|
+ path, ...meta,
|
|
|
+ emittedChars: 0, finalChars: 0, share: 0, allocatedShare: 0, clipped: false,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /** A candidate rendered source into the response. */
|
|
|
+ recordRender(path: string, render: ExploreRenderMode, sourceChars: number, clipped: boolean): void {
|
|
|
+ const rec = this.files.get(path);
|
|
|
+ if (!rec) return;
|
|
|
+ rec.render = render;
|
|
|
+ rec.emittedChars = sourceChars;
|
|
|
+ rec.clipped = clipped;
|
|
|
+ rec.skipped = undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * A candidate was passed over before rendering. First reason wins — the
|
|
|
+ * blanket `max-files` sweep must not overwrite a file's specific reason.
|
|
|
+ */
|
|
|
+ recordSkip(path: string, reason: ExploreSkipReason): void {
|
|
|
+ const rec = this.files.get(path);
|
|
|
+ if (!rec || rec.render || rec.skipped) return;
|
|
|
+ rec.skipped = reason;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Explore returned early (no subgraph). Emits a minimal record. */
|
|
|
+ finishEmpty(reason: string): void {
|
|
|
+ this.note = reason;
|
|
|
+ this.emit(this.buildReport('', 0, 0, 0));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Final pass: attribute the FINAL text's bytes back to files (so the hard
|
|
|
+ * ceiling's truncation is reflected in what each file actually delivered),
|
|
|
+ * then emit.
|
|
|
+ *
|
|
|
+ * `allocatedChars` is the pre-truncation length — the size the render loop
|
|
|
+ * *chose*. Reporting both is the point: the allocator's decision and the
|
|
|
+ * agent's delivered payload diverge exactly when the ceiling cuts, and
|
|
|
+ * conflating them is how a dropped trailing file goes unnoticed.
|
|
|
+ */
|
|
|
+ finish(finalText: string, allocatedChars: number, hardCeiling: number, filesIncluded: number): void {
|
|
|
+ try {
|
|
|
+ const perFile = attributeSourceBytes(finalText);
|
|
|
+ const envelope = finalText.length;
|
|
|
+ for (const rec of this.files.values()) {
|
|
|
+ rec.finalChars = perFile.get(rec.path) ?? 0;
|
|
|
+ rec.share = envelope > 0 ? rec.finalChars / envelope : 0;
|
|
|
+ rec.allocatedShare = allocatedChars > 0 ? rec.emittedChars / allocatedChars : 0;
|
|
|
+ // Rendered into `lines` but absent from the final text → the hard
|
|
|
+ // ceiling dropped its whole section.
|
|
|
+ if (rec.render && rec.render !== 'stale-omitted' && rec.finalChars === 0) {
|
|
|
+ rec.render = 'dropped';
|
|
|
+ rec.clipped = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.emit(this.buildReport(finalText, allocatedChars, hardCeiling, filesIncluded));
|
|
|
+ } catch {
|
|
|
+ // A diagnostic must never fail an explore call.
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private buildReport(
|
|
|
+ finalText: string,
|
|
|
+ allocatedChars: number,
|
|
|
+ hardCeiling: number,
|
|
|
+ filesIncluded: number,
|
|
|
+ ): ExploreDiagnosticReport {
|
|
|
+ const envelope = finalText.length;
|
|
|
+ const records = [...this.files.values()];
|
|
|
+ const rendered = records.filter((r) => r.finalChars > 0);
|
|
|
+ const sourceChars = rendered.reduce((s, r) => s + r.finalChars, 0);
|
|
|
+ return {
|
|
|
+ tool: 'codegraph_explore',
|
|
|
+ query: this.query,
|
|
|
+ projectRoot: this.projectRoot,
|
|
|
+ indexedFileCount: this.indexedFileCount,
|
|
|
+ note: this.note || undefined,
|
|
|
+ budget: {
|
|
|
+ maxOutputChars: this.budget.maxOutputChars,
|
|
|
+ maxCharsPerFile: this.budget.maxCharsPerFile,
|
|
|
+ maxFiles: this.maxFiles,
|
|
|
+ hardCeiling,
|
|
|
+ },
|
|
|
+ envelope: {
|
|
|
+ chars: envelope,
|
|
|
+ allocatedChars,
|
|
|
+ overBudget: allocatedChars > this.budget.maxOutputChars,
|
|
|
+ truncated: allocatedChars > hardCeiling,
|
|
|
+ sourceChars,
|
|
|
+ sourceShare: envelope > 0 ? sourceChars / envelope : 0,
|
|
|
+ metaChars: envelope - sourceChars,
|
|
|
+ metaShare: envelope > 0 ? (envelope - sourceChars) / envelope : 0,
|
|
|
+ },
|
|
|
+ selection: {
|
|
|
+ scoreFloor: this.scoreFloor,
|
|
|
+ maxGraph: this.maxGraph,
|
|
|
+ graphGateThreshold: this.graphGateThreshold,
|
|
|
+ graphGateApplied: this.graphGateApplied,
|
|
|
+ filesGrouped: this.stages.grouped,
|
|
|
+ filesPastScoreFloor: this.stages.pastScoreFloor,
|
|
|
+ filesPastLowValueFilter: this.stages.pastLowValueFilter,
|
|
|
+ filesRanked: this.stages.pastRelevanceGate,
|
|
|
+ filesRenderedByLoop: filesIncluded,
|
|
|
+ filesInFinalOutput: rendered.length,
|
|
|
+ },
|
|
|
+ files: records
|
|
|
+ .slice()
|
|
|
+ .sort((a, b) => b.emittedChars - a.emittedChars || b.finalChars - a.finalChars || a.rank - b.rank)
|
|
|
+ .map((r) => ({
|
|
|
+ path: r.path,
|
|
|
+ rank: r.rank,
|
|
|
+ score: r.score,
|
|
|
+ graphScore: round6(r.graphScore),
|
|
|
+ termHits: r.termHits,
|
|
|
+ nodes: r.nodes,
|
|
|
+ named: r.named,
|
|
|
+ central: r.central,
|
|
|
+ entry: r.entry,
|
|
|
+ spine: r.spine,
|
|
|
+ lowValue: r.lowValue,
|
|
|
+ generated: r.generated,
|
|
|
+ render: r.render ?? null,
|
|
|
+ skipped: r.skipped ?? null,
|
|
|
+ clipped: r.clipped,
|
|
|
+ emittedChars: r.emittedChars,
|
|
|
+ finalChars: r.finalChars,
|
|
|
+ share: round6(r.share),
|
|
|
+ allocatedShare: round6(r.allocatedShare),
|
|
|
+ })),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private emit(report: ExploreDiagnosticReport): void {
|
|
|
+ try {
|
|
|
+ if (this.sink.kind === 'file') {
|
|
|
+ appendFileSync(this.sink.path, JSON.stringify(report) + '\n', 'utf-8');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (this.sink.json) {
|
|
|
+ process.stderr.write(JSON.stringify(report, null, 2) + '\n');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ process.stderr.write(renderTable(report) + '\n');
|
|
|
+ } catch {
|
|
|
+ // Unwritable sidecar / closed stderr must not fail the explore call.
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const round6 = (n: number) => Math.round(n * 1e6) / 1e6;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Attribute the final response's source bytes back to files by walking the
|
|
|
+ * rendered markdown: a ``**`path`**`` section header followed by a fenced code
|
|
|
+ * block. Reading the FINAL text (rather than trusting the render loop's running
|
|
|
+ * total) is what makes the numbers truthful — it accounts for the hard-ceiling
|
|
|
+ * truncation that can drop whole trailing sections after they were "emitted".
|
|
|
+ *
|
|
|
+ * Line numbering is on by default, so a source line that is itself a ``` fence
|
|
|
+ * arrives as `42\t```` and cannot close the block early.
|
|
|
+ */
|
|
|
+export function attributeSourceBytes(finalText: string): Map<string, number> {
|
|
|
+ const out = new Map<string, number>();
|
|
|
+ if (!finalText) return out;
|
|
|
+ const lines = finalText.split('\n');
|
|
|
+ let current: string | null = null;
|
|
|
+ let inFence = false;
|
|
|
+ let acc: string[] = [];
|
|
|
+ const flush = () => {
|
|
|
+ if (current && acc.length > 0) {
|
|
|
+ out.set(current, (out.get(current) ?? 0) + acc.join('\n').length);
|
|
|
+ }
|
|
|
+ acc = [];
|
|
|
+ };
|
|
|
+ for (const line of lines) {
|
|
|
+ if (!inFence) {
|
|
|
+ const header = /^\*\*`([^`]+)`\*\*/.exec(line);
|
|
|
+ if (header) {
|
|
|
+ current = header[1]!;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (current && line.startsWith('```')) {
|
|
|
+ inFence = true;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (line === '```') {
|
|
|
+ inFence = false;
|
|
|
+ flush();
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ acc.push(line);
|
|
|
+ }
|
|
|
+ // Unterminated fence (final-ceiling truncation cut mid-block): count it.
|
|
|
+ if (inFence) flush();
|
|
|
+ return out;
|
|
|
+}
|
|
|
+
|
|
|
+/** Human-readable stderr rendering of the JSON report. */
|
|
|
+export function renderTable(report: ExploreDiagnosticReport): string {
|
|
|
+ const { budget, envelope: env, selection: sel, files } = report;
|
|
|
+
|
|
|
+ const out: string[] = [];
|
|
|
+ out.push('');
|
|
|
+ out.push(`codegraph explore diagnostic — "${report.query}"`);
|
|
|
+ out.push(` project ${report.projectRoot} · ${num(report.indexedFileCount)} files indexed`);
|
|
|
+ if (report.note) out.push(` note: ${report.note}`);
|
|
|
+ out.push(
|
|
|
+ ` envelope ${num(env.chars)} chars delivered · ${num(env.allocatedChars)} allocated` +
|
|
|
+ ` of ${num(budget.maxOutputChars)} budget (hard ceiling ${num(budget.hardCeiling)})` +
|
|
|
+ `${env.overBudget ? ' [over budget]' : ''}${env.truncated ? ' [TRUNCATED]' : ''}`,
|
|
|
+ );
|
|
|
+ out.push(
|
|
|
+ ` source ${num(env.sourceChars)} (${pct(env.sourceShare)})` +
|
|
|
+ ` · meta ${num(env.metaChars)} (${pct(env.metaShare)})` +
|
|
|
+ ` · per-file cap ${num(budget.maxCharsPerFile)}`,
|
|
|
+ );
|
|
|
+ out.push(
|
|
|
+ ` files ${num(sel.filesGrouped)} grouped` +
|
|
|
+ ` → ${num(sel.filesPastScoreFloor)} past score floor (>=${sel.scoreFloor})` +
|
|
|
+ ` → ${num(sel.filesPastLowValueFilter)} past low-value filter` +
|
|
|
+ ` → ${num(sel.filesRanked)} past relevance gate` +
|
|
|
+ ` → ${num(sel.filesInFinalOutput)} in output (maxFiles ${num(budget.maxFiles)})`,
|
|
|
+ );
|
|
|
+ out.push(
|
|
|
+ ` relevance gate ${sel.graphGateApplied ? 'applied' : 'not applied'}` +
|
|
|
+ ` at graph >= ${sel.graphGateThreshold.toFixed(5)} (6% of max ${sel.maxGraph.toFixed(5)})`,
|
|
|
+ );
|
|
|
+ out.push('');
|
|
|
+
|
|
|
+ // Allocated (not delivered) is the allocator's own decision — the number the
|
|
|
+ // budget work is about. Delivered is what the agent got. They differ only
|
|
|
+ // when the ceiling truncated; showing both makes that divergence obvious.
|
|
|
+ const shown = files.filter((f) => f.emittedChars > 0 || f.finalChars > 0);
|
|
|
+ if (shown.length > 0) {
|
|
|
+ out.push(' # alloc% deliv% bytes score graph hits flags render file');
|
|
|
+ for (const f of shown) {
|
|
|
+ out.push(
|
|
|
+ ' ' +
|
|
|
+ String(f.rank).padStart(2) + ' ' +
|
|
|
+ pct(f.allocatedShare).padStart(6) + ' ' +
|
|
|
+ pct(f.share).padStart(6) + ' ' +
|
|
|
+ num(f.emittedChars).padStart(7) + ' ' +
|
|
|
+ String(f.score).padStart(5) + ' ' +
|
|
|
+ f.graphScore.toFixed(5).padStart(7) + ' ' +
|
|
|
+ String(f.termHits).padStart(4) + ' ' +
|
|
|
+ flagString(f).padEnd(19) + ' ' +
|
|
|
+ ((f.render ?? '-') + (f.clipped ? '*' : '')).padEnd(9) + ' ' +
|
|
|
+ f.path,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ out.push(' (bytes = source allocated by the render loop; deliv% = 0 means the hard ceiling dropped the section)');
|
|
|
+ out.push(' (* = clipped: some source in this file was elided, windowed, or its section dropped)');
|
|
|
+ } else {
|
|
|
+ out.push(' (no file source in the final output)');
|
|
|
+ }
|
|
|
+
|
|
|
+ const skipped = files.filter((f) => f.emittedChars === 0 && f.finalChars === 0);
|
|
|
+ if (skipped.length > 0) {
|
|
|
+ out.push('');
|
|
|
+ out.push(' ranked but never rendered:');
|
|
|
+ for (const f of skipped.slice(0, 15)) {
|
|
|
+ out.push(
|
|
|
+ ` #${String(f.rank).padStart(2)} ${f.path} — ${f.skipped ?? f.render ?? 'not reached'}` +
|
|
|
+ ` (score ${f.score}, graph ${f.graphScore.toFixed(5)}, hits ${f.termHits})`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (skipped.length > 15) out.push(` … and ${skipped.length - 15} more`);
|
|
|
+ }
|
|
|
+ return out.join('\n');
|
|
|
+}
|
|
|
+
|
|
|
+function flagString(f: ExploreDiagnosticFile): string {
|
|
|
+ const flags: string[] = [];
|
|
|
+ if (f.named) flags.push('named');
|
|
|
+ if (f.entry) flags.push('entry');
|
|
|
+ if (f.central) flags.push('central');
|
|
|
+ if (f.spine) flags.push('spine');
|
|
|
+ if (f.lowValue) flags.push('low-value');
|
|
|
+ if (f.generated) flags.push('generated');
|
|
|
+ return flags.join(' ') || '-';
|
|
|
+}
|