diff --git a/src/host/fold.ts b/src/host/fold.ts index 73829b6..2524168 100644 --- a/src/host/fold.ts +++ b/src/host/fold.ts @@ -123,6 +123,14 @@ export interface TimelineState { * to keep the state plain JSON for the projection cache. */ pendingShadowedSeqs?: number[] + /** + * The seq of the compaction/prune event that armed `pendingShadowedSeqs` — + * the shadowed path rewrites that event's `tokens` from the gross shadow + * price to the NET freed amount (removed nodes minus the synchronous + * replacement), so the row matches the drop the trend chart shows. Same + * arm/remove lifecycle as `pendingShadowedSeqs`. + */ + pendingShadowEventSeq?: number } /** Keep only the trailing `maxTurns` turn-runs of a request timeline. */ @@ -341,7 +349,9 @@ function applySurface( // persisted-state precondition (see TimelineState) and would fail the // whole session's projection-cache write. const shadowedSeqs = st.pendingShadowedSeqs + const shadowEventSeq = st.pendingShadowEventSeq delete st.pendingShadowedSeqs + delete st.pendingShadowEventSeq const op = ev.surfaceOp as { op?: string; start?: number; end?: number } | null | undefined if (op !== null && typeof op === 'object' && op.op === 'replace') { @@ -362,6 +372,15 @@ function applySurface( st.surface = kept st.sums[cat] += node.tokens st.surface.push(node) + // Rewrite the metering event's row from its gross shadow price to the + // NET freed amount (the replacement re-adds its own tokens), so the + // number matches the drop the trend chart shows. The record is cloned: + // the events array's elements are shared with the persisted state. + if (shadowEventSeq !== undefined) { + const removedSum = removed.reduce((sum, n) => sum + n.tokens, 0) + const i = st.events.findIndex(e => e.seq === shadowEventSeq) + if (i >= 0) st.events[i] = { ...st.events[i], tokens: Math.max(0, removedSum - node.tokens) } + } return node } let si = -1 @@ -635,6 +654,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // event synchronously shadows exactly these node seqs. if (data && Array.isArray(data.shadowedSeqs)) { s.pendingShadowedSeqs = data.shadowedSeqs.filter((x): x is number => typeof x === 'number') + s.pendingShadowEventSeq = event.seq } s.events.push({ seq: event.seq, time: event.time, kind: event.type === 'compaction/summary' ? 'compaction' : 'prune', diff --git a/src/host/timeline.ts b/src/host/timeline.ts index 505bb51..ccb6101 100644 --- a/src/host/timeline.ts +++ b/src/host/timeline.ts @@ -147,6 +147,7 @@ const timelineStateSchema = z.object({ archiveFloor: z.number().optional(), callNames: z.record(z.string(), z.string()), pendingShadowedSeqs: z.array(z.number()).optional(), + pendingShadowEventSeq: z.number().optional(), }) as unknown as z.ZodType /** diff --git a/tests/host.spec.ts b/tests/host.spec.ts index d0ec9fa..b25a534 100644 --- a/tests/host.spec.ts +++ b/tests/host.spec.ts @@ -163,6 +163,7 @@ test('host half: projection unit, fold semantics, attribution, retention, determ assert.equal(compactEv.step, undefined, 'no step for a trailing event') assert.equal(compactEv.fromTurn, 1, 'trailing event still knows the request before it') assert.equal(compactEv.fromStep, 1, 'trailing event keeps its from-step') + assert.equal(compactEv.tokens, 5000, 'a metering event with no synchronous replacement keeps the gross shadow price') // -- determinism + reference-stability of the projection contract -- // Unrelated events must return the SAME state reference (Object.is gates the @@ -236,6 +237,10 @@ test('host half: projection unit, fold semantics, attribution, retention, determ assert.equal(shadow.current.tool, 13, 'shadowed seqs beyond the range end are removed too (only the replacement survives)') assert.equal(shadow.current.user, 18, 'unrelated nodes keep their price (40 chars: 10 + 4 + 4)') assert.equal(shadow.current.total, 18 + 13, 'total reflects the seq-based removal') + // the prune row reads as the NET freed amount — what the trend chart's bar + // drop shows — not the gross shadow price: 64 shadowed - 13 replacement + const pruneEv = shadow.events.find(e => e.kind === 'prune') + assert.equal(pruneEv.tokens, 51, 'prune event shows net freed (removed nodes minus the synchronous replacement)') // no tool/call events in this log, so no call name is registered — the tool // label stays empty (never a crash) assert.equal(shadow.nodes.find(n => n.seq === 5).tool, undefined, 'no registered call name -> no tool label')