From 76387b043bdfa760f6e0c9a772dbaa45689adffc Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 2 Oct 2026 21:18:51 +0300 Subject: [PATCH] coverage meter one-truth fix + chunk-budget setting UI: fetchGraphRead resolves through the shared core resolver (the stale 4096 fallback filled the ring at half the 8192 truth); Graph section gains the extraction chunk budget row (floor 500, empty = factory) and the master switch composes instead of replacing the section --- components/extraction-coverage.tsx | 24 +++---- components/settings/appearance-section.tsx | 74 ++++++++++++++++++---- tests/render-extraction-coverage.test.tsx | 53 ++++++++++++++++ tests/render-settings.test.tsx | 31 +++++++-- 4 files changed, 152 insertions(+), 30 deletions(-) diff --git a/components/extraction-coverage.tsx b/components/extraction-coverage.tsx index c09c998..4cbea19 100644 --- a/components/extraction-coverage.tsx +++ b/components/extraction-coverage.tsx @@ -7,6 +7,7 @@ import { useSettingsSaved } from "@/components/settings-signal"; import { formatTokens } from "@/components/usage-format"; import { Hint } from "@/components/ui/hint"; import { timeAgo } from "@/components/sidebar"; +import { resolveSettings } from "@/src/core"; /** * The extraction-coverage surfaces (extraction-UX redesign, owner-locked @@ -53,25 +54,26 @@ async function fetchCoverage(chatId: string): Promise { } } -/** The ring's denominator + visibility: the graph section of the settings - * record (both fields optional — absent means enabled/4096, the factory - * resolution; see GraphSettings). Null on fetch failure → no ring. */ +/** The ring's denominator + visibility, resolved through the SAME core + * resolver the server's effective settings use (fix, 2026-10-02): the + * meter denominates at exactly the number the extraction trigger waits + * for — the old hand-rolled `?? 4096` fallback drifted from the factory + * default (8192) and the ring filled at half the truth. Null on fetch + * failure → no ring. */ export interface GraphRead { enabled: boolean; chunkTokens: number; } -async function fetchGraphRead(): Promise { +/** Exported for the render pin (the ringFill precedent): resolves the + * raw settings delta to the factory-true graph section. */ +export async function fetchGraphRead(): Promise { try { const res = await fetch("/api/settings"); if (!res.ok) return null; - const body = (await res.json()) as { settings?: { graph?: { enabled?: boolean; extractionChunkTokens?: number } } }; - const graph = body.settings?.graph; - if (!graph) return null; - return { - enabled: graph.enabled !== false, - chunkTokens: graph.extractionChunkTokens ?? 4096, - }; + const body = (await res.json()) as { settings?: Parameters[0] }; + const graph = resolveSettings(body.settings ?? null).graph; + return { enabled: graph.enabled, chunkTokens: graph.extractionChunkTokens }; } catch { return null; } diff --git a/components/settings/appearance-section.tsx b/components/settings/appearance-section.tsx index b661e83..3f3e97d 100644 --- a/components/settings/appearance-section.tsx +++ b/components/settings/appearance-section.tsx @@ -31,7 +31,12 @@ */ import { useEffect, useRef, useState } from "react"; -import type { AppearanceSettings, GraphSettings } from "@/src/core"; +import { + EXTRACTION_CHUNK_TOKENS_FLOOR, + resolveSettings, + type AppearanceSettings, + type GraphSettings, +} from "@/src/core"; import { MenuShell } from "@/components/menu-shell"; import { Hint } from "@/components/ui/hint"; import { useAppearance } from "./appearance.tsx"; @@ -346,18 +351,61 @@ export function GraphSection({ onSectionChange: (next: GraphSettings) => void; }) { // Wave 7 collapse (owner ruling 2026-09-23): the section is the MASTER - // SWITCH ONLY. The embedding/clustering/labeling rungs and the BYOK - // embedding-model picker are gone — clustering is the free birth-session - // partition derived on read, labels ARE session names, and the prober's - // graph tools read the ledgers directly. There is nothing left to - // configure but on/off. + // SWITCH — the embedding/clustering/labeling rungs are gone; clustering + // is the free birth-session partition derived on read, labels ARE session + // names. The chunk budget (2026-10-02) joined it as the section's second + // and only other control: it denominates the coverage ring AND gates the + // auto-extraction trigger (turn settle ≥ budget fires the run). + // + // Resolved through the SAME core resolver the server's effective + // settings use (one source of truth, the 4096-fallback fix 2026-10-02): + // the hint and placeholder report exactly the number the trigger waits + // for — never a second hand-rolled default. + const resolved = resolveSettings(graph === undefined ? null : { version: 1, graph }).graph; return ( - - onSectionChange({ enabled: on })} - /> - + <> + + + // Compose, never replace-whole (the section PUT is, but the + // row must not drop its sibling field — fixed with the budget + // row's arrival: the old { enabled: on } wiped a stored budget). + onSectionChange({ ...graph, enabled: on }) + } + /> + + + { + // Empty = factory default (the fix-wave discipline): absence is + // the unset for numbers — Number("") is 0, so clamping would + // write the floor instead of unsetting. + if (v === "") { + onSectionChange({ ...graph, extractionChunkTokens: undefined }); + return; + } + const n = Number(v); + if (!Number.isFinite(n) || n <= 0) return; // bad input: no write + // Mirror the server sanitize exactly: floor only (cost + // protection), no ceiling — a big budget is the user's call. + onSectionChange({ + ...graph, + extractionChunkTokens: Math.max(EXTRACTION_CHUNK_TOKENS_FLOOR, Math.round(n)), + }); + }} + /> + + ); } \ No newline at end of file diff --git a/tests/render-extraction-coverage.test.tsx b/tests/render-extraction-coverage.test.tsx index ea91fac..ed933dd 100644 --- a/tests/render-extraction-coverage.test.tsx +++ b/tests/render-extraction-coverage.test.tsx @@ -8,6 +8,7 @@ import { CoverageMenuBody, CoverageRingDetail, CoverageRingGlyph, + fetchGraphRead, ringFill, type SessionCoverage, } from "../components/extraction-coverage.tsx"; @@ -34,6 +35,58 @@ function coverage(over: Partial = {}): SessionCoverage { }; } +describe("fetchGraphRead — one truth with the extraction trigger (2026-10-02)", () => { + // The meter's denominator MUST be the number the server's + // effectiveSettings gate waits for. The old hand-rolled fallback + // read the raw delta and guessed `?? 4096` — a stale pre-setting-era + // constant — so an unset budget filled the ring at half the truth + // (owner repro: 6k shown full, extraction correctly silent at 8192). + // Now the raw delta resolves through the SAME core resolver. + const stub = (payload: unknown, ok = true) => { + const real = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify(payload), { status: ok ? 200 : 500 })) as typeof fetch; + return () => { + globalThis.fetch = real; + }; + }; + + it("an absent graph section denominates at the factory 8192 — never 4096, never a missing ring", async () => { + const restore = stub({ settings: { version: 1 } }); + const read = await fetchGraphRead(); + restore(); + assert.deepEqual(read, { enabled: true, chunkTokens: 8192 }); + }); + + it("the owner repro shape — graph section present, budget never written — is 8192", async () => { + const restore = stub({ settings: { version: 1, graph: { enabled: true } } }); + const read = await fetchGraphRead(); + restore(); + assert.equal(read?.chunkTokens, 8192, "the record lacks the key: factory, not the stale 4096"); + }); + + it("first boot (settings null) resolves factory — the ring shows, at the true budget", async () => { + const restore = stub({ settings: null }); + const read = await fetchGraphRead(); + restore(); + assert.deepEqual(read, { enabled: true, chunkTokens: 8192 }); + }); + + it("a custom budget rides through verbatim", async () => { + const restore = stub({ settings: { version: 1, graph: { enabled: true, extractionChunkTokens: 2000 } } }); + const read = await fetchGraphRead(); + restore(); + assert.equal(read?.chunkTokens, 2000); + }); + + it("fetch failure → null → no ring", async () => { + const restore = stub({}, false); + const read = await fetchGraphRead(); + restore(); + assert.equal(read, null); + }); +}); + describe("ringFill (the donut's fold, pure)", () => { it("fills uncovered/chunk, clamped to [0,1]", () => { assert.equal(ringFill(0, 4096), 0); diff --git a/tests/render-settings.test.tsx b/tests/render-settings.test.tsx index e15337e..d5e2ee1 100644 --- a/tests/render-settings.test.tsx +++ b/tests/render-settings.test.tsx @@ -166,12 +166,15 @@ describe("AgentSection (thin SSR)", () => { }); }); -describe("GraphSection — the collapsed master switch (wave 7)", () => { - // Wave 7 (owner ruling 2026-09-23): the section is the master switch - // ONLY — the embedding/clustering/labeling rungs and the BYOK model - // picker are gone (clustering is the free birth-session partition, - // labels ARE session names). One row, one toggle. - it("renders the single master-switch row", () => { +describe("GraphSection — master switch + chunk budget (wave 7 + 2026-10-02)", () => { + // Wave 7 (owner ruling 2026-09-23): the section is the master switch — + // the embedding/clustering/labeling rungs and the BYOK model picker are + // gone (clustering is the free birth-session partition, labels ARE + // session names). The chunk budget (2026-10-02) joined as the second and + // only other control — it denominates the coverage ring and gates the + // auto-extraction trigger, resolved through the same core resolver the + // server uses (the 4096-fallback fix). + it("renders the master-switch row", () => { const html = renderToStaticMarkup( {}} />); assert.ok(html.includes('data-testid="graph-row-enabled"')); assert.ok(!html.includes("graph-row-embedding"), "the dead rungs render nothing"); @@ -185,6 +188,22 @@ describe("GraphSection — the collapsed master switch (wave 7)", () => { const html = renderToStaticMarkup( {}} />); assert.ok(tagOf(html, "toggle-graph-enabled").includes('aria-checked="false"')); }); + + it("the chunk-budget row: unset shows EMPTY with the factory placeholder + hint — one truth with the trigger", () => { + const html = renderToStaticMarkup( {}} />); + const input = tagOf(html, "input-chunk-tokens"); + assert.ok(input.includes('placeholder="8192"'), "the placeholder IS the resolved factory default"); + assert.ok(!/value="\d/.test(input), "unset renders EMPTY, not a materialized 8192"); + assert.ok(html.includes(">8192 tok { + const html = renderToStaticMarkup( + {}} />, + ); + assert.ok(tagOf(html, "input-chunk-tokens").includes('value="6000"')); + assert.ok(html.includes(">6000 tok {