mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
perf(token-meter): commit the surface fold in place through a plan/commit pair
foldSurfaceTokens rebuilt the priced surface on every surface event: an append allocated [...nodes, node] and a replacement copied the whole array before splicing, charging every well-formed event O(surface) for an atomicity property only malformed events need. Benchmarks put the copy at ~99.9% of an append's cost (100µs at a 50k-node surface vs 0.1µs for pricing) with O(S²) accumulation over a session, inside the synchronous session/event publication path. Split the fold into the session core's planSurfaceEvent/applySurfacePlan shape: planSurfaceTokens performs every fallible step against the read-only surface, commitSurfaceTokens applies the plan in place and is infallible by construction. _foldEvent plans first, runs the remaining fallible anchor validation, and only then commits, so retry identity is preserved by ordering instead of by allocation. Appends drop to amortized O(1) (100.3µs -> 1.9µs at 50k nodes); replacements keep their O(surface) findIndex but stop paying the extra full copy (21µs -> 4.2µs). A new regression test pins the one hazard this introduces: an event whose surface plan is valid but whose later anchor validation throws must leave the priced surface and running total uncommitted across repeated failures.
This commit is contained in:
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.md
|
||||
2026-08-24-token-meter-surface-fold-plan-commit.md: 878b2634c18e6bfbf5c341260659028f92399e96
|
||||
2026-08-24-token-meter-surface-fold-plan-commit.zh.md: 7707bf645ecc26098835e6922f48911927ce36f1
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Token-meter surface fold commits in place through a plan/commit pair
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-24-token-meter-surface-fold-plan-commit.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`foldSurfaceTokens` rebuilt the meter's priced surface on every surface event: an append allocated `[...nodes, node]` and a replacement copied the whole array before splicing. The copy existed for one property — a throw must leave the caller's `ReplayState` untouched so a malformed event fails identically on every retry — but it charged every WELL-FORMED event O(surface) for it. Benchmarks on this fold showed the copy was ~99.9% of an append's cost (100µs at a 50k-node surface versus 0.1µs for the pricing itself), and successive appends accumulate O(S²) over a session's life, concentrated in exactly the long sessions users report as sluggish. The token meter folds inside the synchronous `session/event` publication path, so this cost lands on the agent loop's streaming appends.
|
||||
|
||||
## Decision
|
||||
|
||||
Split the fold into the session core's existing `planSurfaceEvent`/`applySurfacePlan` shape: `planSurfaceTokens` performs every fallible step (message pricing, replacement-range resolution) against the read-only surface and returns a `SurfaceTokenPlan`; `commitSurfaceTokens` applies a plan in place — `push` for an append, one `splice` for a replacement — and is infallible by construction. `TokenMeter._foldEvent` plans first, runs the remaining fallible anchor validation (step pairing, provider-chunk provenance), and only then commits, so retry identity is preserved by ordering instead of by allocation. Appends drop from O(surface) to amortized O(1); replacements keep their O(surface) `findIndex` but stop paying the extra full copy.
|
||||
|
||||
`measure()` still detaches its result with `structuredClone` + `deepFreeze`, so in-place mutation of the meter-owned array never escapes to callers.
|
||||
|
||||
## Testing
|
||||
|
||||
The existing malformed-replay suite already pins retry identity (`expectRepeatedFailure` asserts the same throw twice for out-of-range replacements, missing step boundaries, and bad provenance). A new regression test covers the hazard this change introduces: an event whose surface plan is valid but whose later anchor validation throws must leave the priced surface and running total uncommitted across repeated failures — under a mis-ordered in-place commit the throw pattern would still match while the surface silently double-counted. The full token-meter and compaction suites exercise both commit arms through real prune and summary replacements.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A seq→index map to make replacements O(1) too.** Rejected for now: index shifts on every splice force an O(surface) rebuild per replacement anyway, and replacements are orders of magnitude rarer than appends (compaction summaries and prune passes only). The append path was the quadratic term.
|
||||
|
||||
**Keeping the allocation and sharing structurally (persistent vector).** Rejected: a dependency or hand-rolled structure for a single internal array is not justified when the plan/commit ordering already provides the atomicity the copy existed for.
|
||||
|
||||
## Consequences
|
||||
|
||||
The fold no longer contributes a quadratic term to long-session append cost; the meter's remaining per-event costs are the `Session.events` snapshot read in `_sync` (addressed independently by the indexed log-read work, PR #1724/#2907) and O(content) pricing, which is inherent. `SurfaceTokenFold` (the old detached-result type) is gone; `surface-fold.ts` is package-internal, so no external consumer changes. The [composer context-meter note](../feature/2026-08-05-composer-context-meter-breakdown.md) records the projection design around this fold.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Agent Note:token-meter surface fold 改为 plan/commit 两段式原地提交
|
||||
|
||||
状态:已实现
|
||||
|
||||
[English](2026-08-24-token-meter-surface-fold-plan-commit.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`foldSurfaceTokens` 在每个 surface 事件上重建计价 surface:append 分配 `[...nodes, node]`,replacement 先整表复制再 splice。这次复制只为一个性质而存在——抛错必须让调用方的 `ReplayState` 保持原样,使同一条畸形事件在每次重试时以完全相同的方式失败——但它让每条**合法**事件都为此付出 O(surface)。针对该 fold 的基准显示复制占 append 成本的约 99.9%(surface 为 5 万节点时每次 100µs,而估价本身仅 0.1µs),且连续 append 在会话生命周期内累计 O(S²),恰好集中在用户反馈卡顿的长会话上。token meter 在同步的 `session/event` 发布路径内折叠,这笔成本直接落在 agent loop 的流式 append 上。
|
||||
|
||||
## 决定
|
||||
|
||||
按 session 核心既有的 `planSurfaceEvent`/`applySurfacePlan` 形态拆分 fold:`planSurfaceTokens` 针对只读 surface 执行所有可失败步骤(消息估价、替换区间解析)并返回 `SurfaceTokenPlan`;`commitSurfaceTokens` 原地应用 plan——append 用 `push`,replacement 用一次 `splice`——并且构造上不可失败。`TokenMeter._foldEvent` 先 plan,再执行剩余的可失败 anchor 校验(step 配对、provider chunk 溯源),最后才 commit,因此重试一致性由执行顺序保证而不再依赖分配。append 从 O(surface) 降为均摊 O(1);replacement 保留 O(surface) 的 `findIndex`,但不再额外整表复制。
|
||||
|
||||
`measure()` 仍以 `structuredClone` + `deepFreeze` 分离结果,所以对 meter 私有数组的原地修改永远不会泄漏给调用方。
|
||||
|
||||
## 测试
|
||||
|
||||
既有的畸形回放套件已钉住重试一致性(`expectRepeatedFailure` 对越界替换、缺失 step 边界、坏溯源各断言两次相同抛错)。新增一个回归测试覆盖本次改动引入的风险点:surface plan 合法但后续 anchor 校验抛错的事件,必须在反复失败后保持计价 surface 与累计总量未提交——若原地提交顺序错误,抛错模式依然匹配而 surface 会悄悄重复计数。完整的 token-meter 与 compaction 套件通过真实的 prune 与 summary 替换覆盖两个 commit 分支。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**用 seq→index 映射把 replacement 也做成 O(1)。** 暂缓:每次 splice 引起的下标移动本就迫使映射按替换做 O(surface) 重建,而 replacement 比 append 少几个数量级(仅 compaction 摘要与 prune 批次)。二次项在 append 路径上。
|
||||
|
||||
**保留分配并用结构共享(持久化向量)。** 否决:为单个内部数组引入依赖或手搓结构并不划算,plan/commit 的顺序已提供复制原本换取的原子性。
|
||||
|
||||
## 后果
|
||||
|
||||
该 fold 不再为长会话 append 成本贡献二次项;meter 剩余的每事件成本是 `_sync` 中的 `Session.events` 快照读取(由索引化日志读取工作独立解决,PR #1724/#2907)与固有的 O(内容) 估价。旧的分离结果类型 `SurfaceTokenFold` 已移除;`surface-fold.ts` 为包内部模块,无外部消费者需要变更。[composer 上下文仪表笔记](../feature/2026-08-05-composer-context-meter-breakdown.zh.md)记录了该 fold 周边的投影设计。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md
|
||||
2026-08-05-composer-context-meter-breakdown.md: a757bcbc8bc57f4c3a16f663a9c922155b8bb575
|
||||
2026-08-05-composer-context-meter-breakdown.zh.md: 02fcfdf89664dcf932509a0d56193bb4e0d83805
|
||||
2026-08-05-composer-context-meter-breakdown.md: 318a1caf7d2494baa9ee72e2719efc22523fdac6
|
||||
2026-08-05-composer-context-meter-breakdown.zh.md: 886108dd9cb197afbfc210e3bdbf1f42cf4b1f8f
|
||||
|
||||
@@ -14,7 +14,7 @@ Three cooperating pieces, one per package boundary:
|
||||
|
||||
`dsh-session` exports the pure `deriveEventMessage(event)` (previously reachable only as a `Session` method, which now delegates to it) so a host-side fold can price surface nodes without a `Session` instance.
|
||||
|
||||
`dsh-token-meter` extracts its pricing heuristic into `src/estimate.ts` and its positional surface fold into `src/surface-fold.ts` — both shared verbatim with the measurement service — and registers a third session projection, `contextBreakdown`, carrying `systemTokens` / `toolsTokens` / `messageTokens`. Envelope figures reprice last-wins on each `request/header` through `canonicalHeader`; the message figure replays `foldSurfaceTokens` over a per-node `{seq, tokens}` list, so it equals `measure().surfaceTokens` at every event boundary by construction and compaction shrinks it the way it shrinks the next request. The shared fold is total and allocation-fresh — it returns the next surface rather than mutating one — which keeps the service's validate-before-commit replay transaction intact: a throw leaves the replay cursor unmoved and the same malformed event fails identically on retry. A replace range absent from the folded surface throws: committed logs are surface-validated at append time, so an unresolvable range is log corruption, not a skippable event.
|
||||
`dsh-token-meter` extracts its pricing heuristic into `src/estimate.ts` (shared verbatim with the measurement service) and registers a third session projection, `contextBreakdown`, carrying `systemTokens` / `toolsTokens` / `messageTokens`. Envelope figures reprice last-wins on each `request/header` through `canonicalHeader`; the message figure rides the O(1) shadow-price fold in `src/surface-projection.ts`, so on fully metered logs it equals `measure().surfaceTokens` at every event boundary and compaction shrinks it by its logged shadow price. The measurement service's own positional fold lives in `src/surface-fold.ts` as a plan/commit pair ([in-place surface commit](../bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.md)): a throw leaves the replay cursor unmoved and the same malformed event fails identically on retry, and a replace range absent from the folded surface throws — committed logs are surface-validated at append time, so an unresolvable range is log corruption, not a skippable event.
|
||||
|
||||
`ui-conversation` moves context occupancy off the stats line (one home per fact) onto a composer-trailing `ContextMeter`: a 14px occupancy ring after the model seat fed by `contextPressure`, click-opening a panel that pairs the provider-exact percent and `~used / capacity` header with a 4px color-segmented bar and `~`-prefixed composition rows. The two vocabularies deliberately never reconcile — the heuristic shares only proportion the bar's colored segments and rows, each marked `~` because the fixed 4-chars-per-token heuristic systematically underprices CJK text and code. (The ring, header, and bar length were provider-exact as shipped here; they now read the provider-anchored `projectedTokens` instead, because the bare sample could not see a compaction — see [the meter's compaction blindness](../bug-fix/2026-08-05-context-meter-blind-to-compaction.md).) The header is one localized sentence (`context.aria`, shared with the ring's accessible name) split around its `{percent}` slot, so each locale owns the reading's position — English leads with it, Chinese trails it — while the reading keeps its own tone; a bar part whose width computes to zero is dropped rather than rendered, because `.segment`'s min-width would otherwise paint a filled sliver at 0% occupancy.
|
||||
|
||||
@@ -28,4 +28,4 @@ Three cooperating pieces, one per package boundary:
|
||||
|
||||
## Consequences
|
||||
|
||||
Token-meter now registers three projection keys; unloading removes all three, and `contextBreakdown` restores from JSON checkpoints (`stateVersion` 1). The stats line dropped its Context group and the ring is the sole context UI. The panel's heuristic rows visibly disagree with the provider-exact header — accepted and signposted by the `~` prefix; improving estimate accuracy (for example CJK-aware weighting) is localized to `estimate.ts` and changes no seam. The legend's purple segment tint is a literal color because the design platform ships no purple static token.
|
||||
Token-meter now registers three projection keys; unloading removes all three, and `contextBreakdown` restores from JSON checkpoints (`stateVersion` 2). The stats line dropped its Context group and the ring is the sole context UI. The panel's heuristic rows visibly disagree with the provider-exact header — accepted and signposted by the `~` prefix; improving estimate accuracy (for example CJK-aware weighting) is localized to `estimate.ts` and changes no seam. The legend's purple segment tint is a literal color because the design platform ships no purple static token.
|
||||
|
||||
@@ -14,7 +14,7 @@ Web 聊天的统计行把上下文占用率作为一个行内数字(`Context N
|
||||
|
||||
`dsh-session` 导出纯函数 `deriveEventMessage(event)`(此前只能通过 `Session` 方法访问,该方法现在委托给它),使 host 侧 fold 无需 `Session` 实例即可为表层节点计价。
|
||||
|
||||
`dsh-token-meter` 把计价启发式抽取到 `src/estimate.ts`、把位置表层折叠抽取到 `src/surface-fold.ts`(两者都与测量服务逐字共享),并注册第三个会话投影 `contextBreakdown`,携带 `systemTokens` / `toolsTokens` / `messageTokens`。envelope 数字在每条 `request/header` 上经 `canonicalHeader` 按后者胜重新计价;消息数字在逐节点 `{seq, tokens}` 列表上重放 `foldSurfaceTokens`,因此它在每个事件边界上按构造等于 `measure().surfaceTokens`,压缩(compaction)会像缩小下一个请求那样缩小它。这份共享折叠是全函数且总是新建数组——返回下一个表层而不是原地改写——从而保留了服务侧「先校验再提交」的重放事务:抛出时重放游标不前进,同一条畸形事件在重试时报同样的错。折叠表层中不存在的替换范围会直接抛出:已提交日志在追加时就经过表层校验,无法解析的范围是日志损坏,而不是可跳过的事件。
|
||||
`dsh-token-meter` 把计价启发式抽取到 `src/estimate.ts`(与测量服务逐字共享),并注册第三个会话投影 `contextBreakdown`,携带 `systemTokens` / `toolsTokens` / `messageTokens`。envelope 数字在每条 `request/header` 上经 `canonicalHeader` 按后者胜重新计价;消息数字搭载 `src/surface-projection.ts` 的 O(1) 影子价折叠,因此在完整计量的日志上它在每个事件边界等于 `measure().surfaceTokens`,压缩(compaction)按已记录的影子价缩小它。测量服务自己的位置折叠位于 `src/surface-fold.ts`,是 plan/commit 两段式([原地表层提交](../bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.zh.md)):抛出时重放游标不前进,同一条畸形事件在重试时报同样的错;折叠表层中不存在的替换范围会直接抛出——已提交日志在追加时就经过表层校验,无法解析的范围是日志损坏,而不是可跳过的事件。
|
||||
|
||||
`ui-conversation` 把上下文占用率从统计行移走(一个事实一个家),放到 composer 尾部的 `ContextMeter`:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,点击弹出的面板把提供方精确的百分比与 `~已用 / 容量` 标题与 4px 分色分段进度条及带 `~` 前缀的组成明细行并列。两套口径刻意永不对账——启发式数字只决定进度条各彩色分段之间的相对比例,并原样显示在明细行中;每个数字都标有 `~`,因为固定的「4 字符≈1 token」启发式会系统性低估 CJK 文本与代码。(本记录落地时,圆环、标题与进度条总长取的是提供方精确值;它们现在改读锚定在提供方读数上的 `projectedTokens`,因为裸样本看不见压缩——见[仪表对压缩的失明](../bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md)。)标题是一整句本地化文案(`context.aria`,与圆环的无障碍名共用),在 `{percent}` 槽位处切开渲染,于是读数的位置由各语言自己决定——英文在前、中文在后——同时读数保留自身独立的强调样式;宽度算出为零的分段直接不渲染,否则 `.segment` 的 min-width 会在 0% 占用时画出一段填充色。
|
||||
|
||||
@@ -28,4 +28,4 @@ Web 聊天的统计行把上下文占用率作为一个行内数字(`Context N
|
||||
|
||||
## 后果
|
||||
|
||||
token-meter 现在注册三个投影键;卸载会移除全部三个,`contextBreakdown` 可从 JSON 检查点恢复(`stateVersion` 为 1)。统计行删除了 Context 分组,圆环成为唯一的上下文 UI。面板的启发式明细行与提供方精确的标题数字肉眼可见地不一致——已接受并以 `~` 前缀标示;提升估算精度(例如按 CJK 加权)只需改动 `estimate.ts`,不涉及任何 seam。图例的紫色分段色值是字面量,因为设计平台没有紫色静态 token。
|
||||
token-meter 现在注册三个投影键;卸载会移除全部三个,`contextBreakdown` 可从 JSON 检查点恢复(`stateVersion` 为 2)。统计行删除了 Context 分组,圆环成为唯一的上下文 UI。面板的启发式明细行与提供方精确的标题数字肉眼可见地不一致——已接受并以 `~` 前缀标示;提升估算精度(例如按 CJK 加权)只需改动 `estimate.ts`,不涉及任何 seam。图例的紫色分段色值是字面量,因为设计平台没有紫色静态 token。
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
import { contextBreakdownProjectionDefinition } from './breakdown-projection.ts'
|
||||
import { contextPressureProjectionDefinition, tokenUsageProjectionDefinition } from './usage-projection.ts'
|
||||
import { estimateContent, estimateHeader, estimateMessage, ROLE_OVERHEAD } from './estimate.ts'
|
||||
import { foldSurfaceTokens } from './surface-fold.ts'
|
||||
import { commitSurfaceTokens, planSurfaceTokens } from './surface-fold.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
|
||||
@@ -181,9 +181,9 @@ export class TokenMeter extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and prepare every fallible part before mutating replay state.
|
||||
* A malformed event remains unread on every retry instead of partially
|
||||
* applying the same mutation more than once.
|
||||
* Run every fallible step — surface plan and anchor validation — before
|
||||
* mutating replay state, so a malformed event remains unread on every
|
||||
* retry instead of half-applying.
|
||||
*/
|
||||
private _foldEvent(session: Session, state: ReplayState, event: SessionEvent): void {
|
||||
let nextHeader = state.header
|
||||
@@ -214,8 +214,8 @@ export class TokenMeter extends Service {
|
||||
break
|
||||
}
|
||||
|
||||
const surface = isSurfaceEvent(event)
|
||||
? foldSurfaceTokens(state.surface, event)
|
||||
const plan = isSurfaceEvent(event)
|
||||
? planSurfaceTokens(state.surface, event)
|
||||
: undefined
|
||||
|
||||
if (event.type === 'assistant/message') {
|
||||
@@ -228,7 +228,7 @@ export class TokenMeter extends Service {
|
||||
|
||||
// assistant/message is surface-mandatory at every append/seed boundary.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const eventTokens = surface!.tokens
|
||||
const eventTokens = plan!.tokens
|
||||
if (event.data.usage !== undefined && nextHeader !== undefined) {
|
||||
const providerAssistantTokens = this._estimateProviderAssistant(
|
||||
session,
|
||||
@@ -262,9 +262,9 @@ export class TokenMeter extends Service {
|
||||
|
||||
state.header = nextHeader
|
||||
state.stepStart = nextStepStart
|
||||
if (surface !== undefined) {
|
||||
state.surface = surface.nodes
|
||||
state.surfaceTokens += surface.deltaTokens
|
||||
if (plan !== undefined) {
|
||||
commitSurfaceTokens(state.surface, plan)
|
||||
state.surfaceTokens += plan.deltaTokens
|
||||
}
|
||||
state.anchor = nextAnchor
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* The measurement service's positional surface fold: the per-node priced
|
||||
* surface `measure()` serves and compaction plans against. The projection
|
||||
* units deliberately do NOT share this fold — their state must stay O(1)
|
||||
* for the persisted checkpoint, so they ride `surface-projection.ts`'s
|
||||
* shadow-price protocol instead. Fully metered logs stay in agreement by
|
||||
* construction: both price through `estimate.ts`, and every logged shadow
|
||||
* price is derived from THIS fold's nodes by the replace producer. A
|
||||
* projection replacement without a claim deliberately folds with zero delta.
|
||||
* units do NOT share this fold — their state must stay O(1) for the
|
||||
* persisted checkpoint, so they ride `surface-projection.ts`'s shadow-price
|
||||
* protocol; the two agree because both price through `estimate.ts` and every
|
||||
* logged shadow price derives from this fold's nodes.
|
||||
*
|
||||
* The fold is a plan/commit pair: {@link planSurfaceTokens} runs every
|
||||
* fallible step read-only and {@link commitSurfaceTokens} mutates in place,
|
||||
* so a throw leaves the caller's state untouched and the same malformed
|
||||
* event fails identically on every retry.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-token-meter/surface-fold
|
||||
*/
|
||||
@@ -16,50 +19,61 @@ import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { TokenSurfaceNode } from './types.ts'
|
||||
import { estimateMessage } from './estimate.ts'
|
||||
|
||||
/** One surface event's placement and cost against the surface preceding it. */
|
||||
export interface SurfaceTokenFold {
|
||||
/** One validated surface transition that has not mutated the priced surface yet. */
|
||||
export interface SurfaceTokenPlan {
|
||||
/** Heuristic price of the event's own message; 0 when it derives none. */
|
||||
readonly tokens: number
|
||||
/** The surface after the event, detached from the input. */
|
||||
readonly nodes: TokenSurfaceNode[]
|
||||
/** Signed change in the surface total: `tokens` minus anything shadowed. */
|
||||
readonly deltaTokens: number
|
||||
/** The priced node the commit inserts for this event. */
|
||||
readonly node: TokenSurfaceNode
|
||||
/** Commit position: `append`, or the inclusive replaced index range. */
|
||||
readonly target: 'append' | { readonly startIdx: number; readonly endIdx: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one surface event onto a priced surface.
|
||||
*
|
||||
* Total and allocation-fresh: the caller assigns the result rather than
|
||||
* mutating in place, so a throw here leaves the caller's state untouched and
|
||||
* the same malformed event fails identically on every retry.
|
||||
* Validate and price one surface event without mutating the surface.
|
||||
* @param nodes - the priced surface preceding this event, in model-visible order.
|
||||
* @param event - the surface event to place.
|
||||
* @returns the event's price, the next surface, and the signed total delta.
|
||||
* @returns the plan for {@link commitSurfaceTokens}.
|
||||
* @throws when a replacement names a range absent from `nodes` — committed
|
||||
* logs are surface-validated at append time, so an unresolvable range is log
|
||||
* corruption and must fail loud rather than skip the event.
|
||||
*/
|
||||
export function foldSurfaceTokens(
|
||||
export function planSurfaceTokens(
|
||||
nodes: readonly TokenSurfaceNode[],
|
||||
event: SurfaceEvent,
|
||||
): SurfaceTokenFold {
|
||||
): SurfaceTokenPlan {
|
||||
const message = deriveEventMessage(event)
|
||||
const tokens = message === null ? 0 : estimateMessage(message)
|
||||
const node = { seq: event.seq, tokens }
|
||||
const op = event.surfaceOp
|
||||
if (op === 'append') {
|
||||
return { tokens, nodes: [...nodes, { seq: event.seq, tokens }], deltaTokens: tokens }
|
||||
return { tokens, deltaTokens: tokens, node, target: 'append' }
|
||||
}
|
||||
const startIdx = nodes.findIndex(node => node.seq === op.start)
|
||||
const endIdx = nodes.findIndex(node => node.seq === op.end)
|
||||
const startIdx = nodes.findIndex(candidate => candidate.seq === op.start)
|
||||
const endIdx = nodes.findIndex(candidate => candidate.seq === op.end)
|
||||
if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) {
|
||||
throw new Error(
|
||||
`token surface: replace at seq ${event.seq} has invalid current range ${op.start}-${op.end}`,
|
||||
)
|
||||
}
|
||||
const removed = nodes
|
||||
.slice(startIdx, endIdx + 1)
|
||||
.reduce((total, node) => total + node.tokens, 0)
|
||||
const next = [...nodes]
|
||||
next.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, tokens })
|
||||
return { tokens, nodes: next, deltaTokens: tokens - removed }
|
||||
let removed = 0
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion -- startIdx..endIdx are validated indices
|
||||
for (let index = startIdx; index <= endIdx; index += 1) removed += nodes[index]!.tokens
|
||||
return { tokens, deltaTokens: tokens - removed, node, target: { startIdx, endIdx } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one validated plan to the priced surface in place; infallible, so it
|
||||
* cannot leave a half-applied surface behind.
|
||||
* @param nodes - the exact priced surface the plan was built against.
|
||||
* @param plan - the transition returned by {@link planSurfaceTokens}.
|
||||
*/
|
||||
export function commitSurfaceTokens(nodes: TokenSurfaceNode[], plan: SurfaceTokenPlan): void {
|
||||
if (plan.target === 'append') {
|
||||
nodes.push(plan.node)
|
||||
return
|
||||
}
|
||||
nodes.splice(plan.target.startIdx, plan.target.endIdx - plan.target.startIdx + 1, plan.node)
|
||||
}
|
||||
|
||||
@@ -467,6 +467,33 @@ describe('malformed replay and listener lifecycle', () => {
|
||||
expectRepeatedFailure(meter(), session, /no matching step\/start/)
|
||||
})
|
||||
|
||||
it('leaves the priced surface uncommitted when a later validation step rejects the event', () => {
|
||||
// A valid append plan whose anchor validation throws: only commit
|
||||
// ordering keeps the surface from double-counting across retries.
|
||||
const session = Session.create(SessionId('bad-step-surface'))
|
||||
appendHeader(session, header('deepseek-v4-flash'))
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'planned but never committed' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [] })
|
||||
const service = meter()
|
||||
const states = (service as unknown as {
|
||||
states: WeakMap<Session, { surface: unknown[]; surfaceTokens: number }>
|
||||
}).states
|
||||
expectRepeatedFailure(service, session, /no matching step\/start/)
|
||||
const state = states.get(session)
|
||||
expect(state?.surface).toEqual([])
|
||||
expect(state?.surfaceTokens).toBe(0)
|
||||
})
|
||||
|
||||
it('clears completed step boundaries and rejects overlapping or late step events', () => {
|
||||
const overlapping = Session.create(SessionId('overlapping-step'))
|
||||
overlapping.append('step/start', { turn: 1, step: 1 })
|
||||
|
||||
Reference in New Issue
Block a user