diff --git a/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java b/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java
index cd2f2d63..8ca2ac7e 100644
--- a/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java
+++ b/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/observability/GraphRagEventSink.java
@@ -82,8 +82,44 @@ record GraphRagEvent(
String scopeFingerprint,
CacheStatus cacheStatus,
String failureCode,
+ TokenUsage tokenUsage,
Instant occurredAt) {
+ /**
+ * Describes a stage that measures no tokens, which is every stage but
+ * context assembly. The overload exists so that adding a measurement one
+ * stage can take does not oblige the other stages to pass {@code null},
+ * where a reader would have to count commas to learn what was omitted.
+ */
+ public GraphRagEvent(
+ UUID operationId,
+ UUID organizationId,
+ Stage stage,
+ Outcome outcome,
+ Duration duration,
+ int inputCount,
+ int outputCount,
+ String modelRouteFingerprint,
+ String scopeFingerprint,
+ CacheStatus cacheStatus,
+ String failureCode,
+ Instant occurredAt) {
+ this(
+ operationId,
+ organizationId,
+ stage,
+ outcome,
+ duration,
+ inputCount,
+ outputCount,
+ modelRouteFingerprint,
+ scopeFingerprint,
+ cacheStatus,
+ failureCode,
+ null,
+ occurredAt);
+ }
+
public GraphRagEvent {
Objects.requireNonNull(operationId, "operationId");
Objects.requireNonNull(organizationId, "organizationId");
@@ -122,6 +158,64 @@ record GraphRagEvent(
}
}
+ /**
+ * Token cost of one assembled generation context.
+ *
+ *
Counts, not content. A token count cannot reconstruct the text it
+ * measures, which is why this record is allowed through a boundary that
+ * refuses queries, prompts and evidence.
+ *
+ *
Deliberately a projection of the query model's own token record rather
+ * than a reference to it. That record exists to enforce a retrieval budget
+ * and is free to change shape for retrieval reasons; a telemetry contract
+ * that moved with it would rewrite dashboards for a decision that had
+ * nothing to do with them. The two overlap today and may diverge.
+ *
+ * @param promptTokens what the model is actually charged for, which
+ * exceeds the sum of the channels below by the
+ * cost of rendering them into one prompt
+ * @param budgetTokens the ceiling {@code promptTokens} was fitted to,
+ * so a collector can express headroom as a ratio
+ * without knowing this deployment's configuration
+ * @param droppedContributions entities, relations and chunks evicted to make
+ * the context fit. This is the input-side
+ * truncation signal: zero means the retrieved
+ * context reached the model whole, and a rising
+ * count means answers are degrading for a reason
+ * no latency or error metric reports.
+ */
+ record TokenUsage(
+ int promptTokens,
+ int systemPromptTokens,
+ int queryTokens,
+ int entityTokens,
+ int relationTokens,
+ int chunkTokens,
+ int budgetTokens,
+ int droppedContributions) {
+
+ public TokenUsage {
+ if (promptTokens < 0
+ || systemPromptTokens < 0
+ || queryTokens < 0
+ || entityTokens < 0
+ || relationTokens < 0
+ || chunkTokens < 0
+ || droppedContributions < 0) {
+ throw new IllegalArgumentException(
+ "token counts must be non-negative");
+ }
+ if (budgetTokens <= 0) {
+ throw new IllegalArgumentException(
+ "budgetTokens must be positive");
+ }
+ }
+
+ public boolean truncated() {
+ return droppedContributions > 0;
+ }
+ }
+
enum Stage {
PARSE,
CHUNK,
diff --git a/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/query/LightRagGroundingAssembler.java b/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/query/LightRagGroundingAssembler.java
index 844c01af..f6c082bf 100644
--- a/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/query/LightRagGroundingAssembler.java
+++ b/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/query/LightRagGroundingAssembler.java
@@ -78,7 +78,11 @@ public PreparedGrounding prepare(
distinctScopes(scopes),
usage,
chunkAllocation.usedTokens());
- return fitAndRender(query, options, grounding);
+ // Counted after merging and before any budget runs, so the reported
+ // eviction is what the budget cost the answer and not what deduplication
+ // saved it.
+ int selected = entities.size() + relations.size() + chunks.size();
+ return fitAndRender(query, options, grounding, selected);
}
public PreparedGrounding consolidate(
@@ -111,8 +115,7 @@ public PreparedGrounding render(
Objects.requireNonNull(options, "options");
Objects.requireNonNull(grounding, "grounding");
PreparedGrounding prepared = renderUnchecked(query, options, grounding);
- int maximumInputTokens = options.contextBudget().maxTotalTokens()
- - options.contextBudget().safetyBufferTokens();
+ int maximumInputTokens = options.contextBudget().maximumInputTokens();
if (prepared.inputTokens() > maximumInputTokens) {
throw new IllegalArgumentException(
"verified grounding exceeds the configured input budget");
@@ -123,10 +126,10 @@ public PreparedGrounding render(
private PreparedGrounding fitAndRender(
String query,
LightRagQueryRequest.Options options,
- LightRagGrounding grounding) {
+ LightRagGrounding grounding,
+ int selectedContributions) {
SecureContextBudget budget = options.contextBudget();
- int maximumInputTokens =
- budget.maxTotalTokens() - budget.safetyBufferTokens();
+ int maximumInputTokens = budget.maximumInputTokens();
TokenMeasurements measurements =
measureTokens(query, options, grounding);
LightRagGrounding candidate =
@@ -155,7 +158,14 @@ private PreparedGrounding fitAndRender(
throw new IllegalArgumentException(
"query and system instructions exceed the configured input budget");
}
- return prepared;
+ return prepared.withDroppedContributions(
+ selectedContributions - contributionCount(candidate));
+ }
+
+ private static int contributionCount(LightRagGrounding grounding) {
+ return grounding.entities().size()
+ + grounding.relations().size()
+ + grounding.chunks().size();
}
private TokenMeasurements measureTokens(
@@ -254,7 +264,8 @@ private PreparedGrounding renderUnchecked(
systemPrompt,
prompt,
references,
- tokenizer.count(prompt));
+ tokenizer.count(prompt),
+ 0);
}
private static LightRagGrounding removeLowestPriority(
@@ -577,13 +588,22 @@ private static String escape(String value) {
.replace("\n", "\\n");
}
+ /**
+ * @param droppedContributions how many merged entities, relations and chunks
+ * the budget evicted before this prompt fit. The
+ * assembler is the only thing that knows the
+ * difference between what retrieval selected and
+ * what the model was shown, so it is the only
+ * thing that can report it.
+ */
public record PreparedGrounding(
LightRagGrounding grounding,
String context,
String systemPrompt,
String prompt,
List references,
- int inputTokens) {
+ int inputTokens,
+ int droppedContributions) {
public PreparedGrounding {
Objects.requireNonNull(grounding, "grounding");
@@ -596,6 +616,21 @@ public record PreparedGrounding(
throw new IllegalArgumentException(
"inputTokens must be non-negative");
}
+ if (droppedContributions < 0) {
+ throw new IllegalArgumentException(
+ "droppedContributions must be non-negative");
+ }
+ }
+
+ PreparedGrounding withDroppedContributions(int dropped) {
+ return new PreparedGrounding(
+ grounding,
+ context,
+ systemPrompt,
+ prompt,
+ references,
+ inputTokens,
+ dropped);
}
}
diff --git a/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/query/SecureContextBudget.java b/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/query/SecureContextBudget.java
index ae9df300..804e5eb2 100644
--- a/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/query/SecureContextBudget.java
+++ b/components/graph-rag-core/src/main/java/com/orgmemory/graphrag/query/SecureContextBudget.java
@@ -22,6 +22,14 @@ public static SecureContextBudget lightRagCompatibleDefaults() {
return new SecureContextBudget(6_000, 8_000, 30_000, 200);
}
+ /**
+ * The largest prompt this budget will render: the total less the buffer held
+ * back for the model's own reply.
+ */
+ public int maximumInputTokens() {
+ return maxTotalTokens - safetyBufferTokens;
+ }
+
public int availableChunkTokens(ContextTokenUsage usage) {
if (usage.entityTokens() > maxEntityTokens) {
throw new IllegalArgumentException("entity context exceeds its budget");
diff --git a/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java b/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java
index 2422b07d..1e14e261 100644
--- a/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java
+++ b/components/graph-rag-core/src/test/java/com/orgmemory/graphrag/observability/GraphRagEventSinkTests.java
@@ -1,8 +1,11 @@
package com.orgmemory.graphrag.observability;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
@@ -29,6 +32,34 @@ void rejectsUnboundedFailureDiagnostics() {
"provider failed: raw payload"));
}
+ @Test
+ void describesAStageThatMeasuredNoTokensAsAbsentRatherThanAsZero() {
+ assertNull(
+ event(GraphRagEventSink.Outcome.SUCCEEDED, null, null).tokenUsage(),
+ "a zero breakdown would claim a measurement the stage never took");
+ }
+
+ @Test
+ void rejectsABudgetThatCannotBoundAnything() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> new GraphRagEventSink.TokenUsage(10, 1, 1, 1, 1, 6, 0, 0),
+ "headroom expressed against a zero budget is a division by zero on every dashboard");
+ }
+
+ @Test
+ void rejectsNegativeTokenCounts() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> new GraphRagEventSink.TokenUsage(10, 1, 1, 1, 1, 6, 100, -1));
+ }
+
+ @Test
+ void treatsAnyEvictionAsTruncationBecauseTheAnswerIsAlreadyIncomplete() {
+ assertFalse(new GraphRagEventSink.TokenUsage(10, 1, 1, 1, 1, 6, 100, 0).truncated());
+ assertTrue(new GraphRagEventSink.TokenUsage(10, 1, 1, 1, 1, 6, 100, 1).truncated());
+ }
+
@Test
void deliversOneEventToEveryConfiguredBackend() {
var otel = new RecordingSink();
diff --git a/components/graph-rag-testkit/src/test/java/com/orgmemory/graphrag/testkit/LightRagQueryRuntimeConformanceTests.java b/components/graph-rag-testkit/src/test/java/com/orgmemory/graphrag/testkit/LightRagQueryRuntimeConformanceTests.java
index 55e7e21b..a5eb7ceb 100644
--- a/components/graph-rag-testkit/src/test/java/com/orgmemory/graphrag/testkit/LightRagQueryRuntimeConformanceTests.java
+++ b/components/graph-rag-testkit/src/test/java/com/orgmemory/graphrag/testkit/LightRagQueryRuntimeConformanceTests.java
@@ -308,33 +308,109 @@ void consolidatedGroundingUsesOneFinalInputBudget() {
260,
20);
LightRagQueryRequest.Options options =
- new LightRagQueryRequest.Options(
- base.options().mode(),
- base.options().outputMode(),
- base.options().responseType(),
- base.options().userInstruction(),
- base.options().topK(),
- base.options().chunkTopK(),
- base.options().relatedChunkNumber(),
- base.options().maximumGraphDepth(),
- base.options().relatedChunkSelection(),
- budget,
- base.options().rerankEnabled(),
- base.options().minimumRerankScore(),
- base.options().minimumVectorSimilarity(),
- base.options().includeHeadings(),
- base.options().streaming());
+ withContextBudget(base.options(), budget);
var prepared = engine.consolidateGrounding(
base.query(),
options,
List.of(first.grounding(), first.grounding()));
- assertTrue(prepared.inputTokens()
- <= budget.maxTotalTokens() - budget.safetyBufferTokens());
+ assertTrue(prepared.inputTokens() <= budget.maximumInputTokens());
assertEquals(
prepared.grounding().evidenceClosure().size(),
prepared.references().size());
+ // Two copies of one grounding merge back into one, so this budget is not what removed
+ // the duplicate — deduplication is, and the model still sees everything retrieval
+ // selected. Reporting that as truncation would put a permanent false positive on the
+ // dashboard, which is why the count separates the two.
+ assertEquals(
+ 0,
+ prepared.droppedContributions(),
+ "merging is not eviction");
+ }
+
+ @Test
+ void aBudgetTooSmallForTheGroundingReportsWhatItEvicted() {
+ LightRagQueryRequest base = request(
+ LightRagQueryMode.MIX,
+ QueryOutputMode.CONTEXT,
+ false,
+ true,
+ false,
+ trustedKeywords());
+ LightRagQueryResult first = engine.execute(base);
+ var whole = engine.consolidateGrounding(
+ base.query(),
+ base.options(),
+ List.of(first.grounding()));
+ int selected = contributionCount(whole.grounding());
+
+ // The system prompt and query alone need roughly half of this, leaving too little for
+ // every entity, relation and chunk the query selected.
+ var prepared = engine.consolidateGrounding(
+ base.query(),
+ withContextBudget(base.options(), new SecureContextBudget(80, 80, 80, 5)),
+ List.of(first.grounding()));
+
+ assertTrue(
+ prepared.inputTokens() <= 75,
+ "the fitted prompt must respect the ceiling it was fitted to");
+ assertTrue(
+ prepared.droppedContributions() > 0,
+ "an eviction nothing reports is an answer quietly made worse to fit");
+ assertEquals(
+ selected - contributionCount(prepared.grounding()),
+ prepared.droppedContributions(),
+ "the reported count is the gap between what retrieval chose and what the model saw");
+ }
+
+ private static int contributionCount(LightRagGrounding grounding) {
+ return grounding.entities().size()
+ + grounding.relations().size()
+ + grounding.chunks().size();
+ }
+
+ private static LightRagQueryRequest.Options withContextBudget(
+ LightRagQueryRequest.Options options,
+ SecureContextBudget budget) {
+ return new LightRagQueryRequest.Options(
+ options.mode(),
+ options.outputMode(),
+ options.responseType(),
+ options.userInstruction(),
+ options.topK(),
+ options.chunkTopK(),
+ options.relatedChunkNumber(),
+ options.maximumGraphDepth(),
+ options.relatedChunkSelection(),
+ budget,
+ options.rerankEnabled(),
+ options.minimumRerankScore(),
+ options.minimumVectorSimilarity(),
+ options.includeHeadings(),
+ options.streaming());
+ }
+
+ @Test
+ void aGroundingThatFitsReportsNoEviction() {
+ LightRagQueryRequest base = request(
+ LightRagQueryMode.MIX,
+ QueryOutputMode.CONTEXT,
+ false,
+ true,
+ false,
+ trustedKeywords());
+ LightRagQueryResult first = engine.execute(base);
+
+ var prepared = engine.consolidateGrounding(
+ base.query(),
+ base.options(),
+ List.of(first.grounding()));
+
+ assertEquals(
+ 0,
+ prepared.droppedContributions(),
+ "a nonzero count here would make every dashboard read truncation that never happened");
}
@Test
diff --git a/core/src/main/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalService.java b/core/src/main/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalService.java
index 73365951..ece3cc7d 100644
--- a/core/src/main/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalService.java
+++ b/core/src/main/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalService.java
@@ -15,12 +15,14 @@
import com.orgmemory.graphrag.cache.CanonicalCacheKeyHasher;
import com.orgmemory.graphrag.model.EvidenceReference;
import com.orgmemory.graphrag.observability.GraphRagEventSink;
+import com.orgmemory.graphrag.query.ContextTokenUsage;
import com.orgmemory.graphrag.query.LightRagGrounding;
import com.orgmemory.graphrag.query.LightRagGroundingAssembler;
import com.orgmemory.graphrag.query.LightRagPreparedQuery;
import com.orgmemory.graphrag.query.LightRagQueryEngine;
import com.orgmemory.graphrag.query.LightRagQueryRequest;
import com.orgmemory.graphrag.query.LightRagQueryResult;
+import com.orgmemory.graphrag.query.SecureContextBudget;
import com.orgmemory.graphrag.storage.ProjectionNamespace;
import com.orgmemory.graphrag.storage.ProjectionPublicationStore;
import java.time.Duration;
@@ -249,13 +251,13 @@ private SecureKnowledgeSearchResult search(
query,
queryOptions,
spaceGroundings);
- emitStage(
+ emitAssembledContext(
operationId,
actor.organizationId(),
- GraphRagEventSink.Stage.ASSEMBLE_CONTEXT,
consolidationStartedAt,
spaceGroundings.size(),
- consolidated.grounding().chunks().size());
+ consolidated,
+ queryOptions.contextBudget());
if (consolidated.grounding().empty()
|| consolidated.grounding().chunks().isEmpty()) {
audit.record(searchAuthorization.command(
@@ -583,6 +585,51 @@ private void emitStage(
}
}
+ /**
+ * Context assembly is the one retrieval stage whose cost is measured in
+ * tokens rather than items, and the only stage that can report how much of
+ * the retrieved context the budget refused to carry. Both numbers were being
+ * computed and discarded.
+ */
+ private void emitAssembledContext(
+ UUID operationId,
+ UUID organizationId,
+ long startedAt,
+ int inputCount,
+ LightRagGroundingAssembler.PreparedGrounding prepared,
+ SecureContextBudget budget) {
+ LightRagGrounding grounding = prepared.grounding();
+ ContextTokenUsage usage = grounding.tokenUsage();
+ try {
+ events.emit(new GraphRagEventSink.GraphRagEvent(
+ operationId,
+ organizationId,
+ GraphRagEventSink.Stage.ASSEMBLE_CONTEXT,
+ GraphRagEventSink.Outcome.SUCCEEDED,
+ Duration.ofNanos(Math.max(
+ 0,
+ System.nanoTime() - startedAt)),
+ inputCount,
+ grounding.chunks().size(),
+ null,
+ null,
+ null,
+ null,
+ new GraphRagEventSink.TokenUsage(
+ prepared.inputTokens(),
+ usage.systemPromptTokens(),
+ usage.queryTokens(),
+ usage.entityTokens(),
+ usage.relationTokens(),
+ grounding.chunkTokens(),
+ budget.maximumInputTokens(),
+ prepared.droppedContributions()),
+ Instant.now()));
+ } catch (RuntimeException ignoredTelemetryFailure) {
+ // Telemetry must never become a retrieval availability dependency.
+ }
+ }
+
private void emitPreparedStage(
UUID operationId,
UUID organizationId,
diff --git a/core/src/test/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalServiceTests.java b/core/src/test/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalServiceTests.java
index db0e0757..824a910c 100644
--- a/core/src/test/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalServiceTests.java
+++ b/core/src/test/java/com/orgmemory/core/knowledge/GraphRagKnowledgeRetrievalServiceTests.java
@@ -429,6 +429,89 @@ void verifiesTheCompleteGraphGroundingBeforeCreatingTheModelInput() {
assertNotNull(fallbackEvent.modelRouteFingerprint());
}
+ /**
+ * The assembler has always measured what one answer costs and how much context the budget
+ * refused to carry. Nothing published either, so a deployment could not tell an expensive
+ * question from a cheap one, nor a whole answer from one that was silently cut to fit.
+ */
+ @Test
+ void contextAssemblyReportsWhatTheAnswerCostAndWhatTheBudgetRefused() {
+ CurrentActor actor = new CurrentActor(
+ USER_ID,
+ ORGANIZATION_ID,
+ null,
+ "User",
+ "user@example.test");
+ PermissionAuditService audit = mock(PermissionAuditService.class);
+ KnowledgeEvidenceScopeResolver scopes =
+ mock(KnowledgeEvidenceScopeResolver.class);
+ ResolvedKnowledgeEvidenceScope allowed = scope(Set.of(ASSET_ID), 1L);
+ when(scopes.resolve(actor, MODEL_ID)).thenReturn(allowed, allowed);
+ LightRagGrounding grounding = grounding();
+ LightRagGroundingAssembler.PreparedGrounding prepared =
+ prepared(grounding, 4);
+ LightRagQueryEngine engine = mock(LightRagQueryEngine.class);
+ LightRagPreparedQuery queryPlan = preparedQueryPlan();
+ when(engine.prepare(any())).thenReturn(queryPlan);
+ when(engine.executePrepared(any(), any())).thenReturn(queryResult(
+ allowed.forKnowledgeSpace(SPACE_ID).authorizationFingerprint(),
+ grounding,
+ true,
+ true));
+ when(engine.consolidateGrounding(any(), any(), any())).thenReturn(prepared);
+ when(engine.renderGrounding(any(), any(), any())).thenReturn(prepared);
+ RelationshipAuthorizationSetPort finalAuthorization =
+ mock(RelationshipAuthorizationSetPort.class);
+ when(finalAuthorization.batchCheck(any())).thenReturn(
+ BatchAuthorizationResult.resolved(
+ Map.of(
+ ResourceRef.of(ORGANIZATION_ID, "knowledge_asset", ASSET_ID),
+ AuthorizationDecision.allow(MODEL_ID)),
+ MODEL_ID));
+ GraphRagEventSink events = mock(GraphRagEventSink.class);
+
+ service(
+ scopes,
+ finalAuthorization,
+ new RecordingRecheckedStore(List.of(
+ candidate(ENTITY_CHUNK_ID),
+ candidate(RELATION_CHUNK_ID),
+ candidate(CHUNK_ID))),
+ engine,
+ rerankPolicy(),
+ audit,
+ events)
+ .search(actor, "What is the leave policy?", 10, "request-tokens");
+
+ ArgumentCaptor captured =
+ ArgumentCaptor.forClass(GraphRagEventSink.GraphRagEvent.class);
+ verify(events, atLeastOnce()).emit(captured.capture());
+ GraphRagEventSink.TokenUsage usage = captured.getAllValues().stream()
+ .filter(value ->
+ value.stage() == GraphRagEventSink.Stage.ASSEMBLE_CONTEXT)
+ .findFirst()
+ .orElseThrow()
+ .tokenUsage();
+
+ assertNotNull(usage, "context assembly is the stage whose cost is measured in tokens");
+ assertEquals(45, usage.promptTokens());
+ assertEquals(4, usage.droppedContributions());
+ assertTrue(usage.truncated());
+ assertEquals(
+ GraphRagRetrievalPolicy.defaults()
+ .contextOptions(10)
+ .contextBudget()
+ .maximumInputTokens(),
+ usage.budgetTokens(),
+ "headroom is only readable if the ceiling travels with the measurement");
+ assertTrue(
+ captured.getAllValues().stream()
+ .filter(value ->
+ value.stage() != GraphRagEventSink.Stage.ASSEMBLE_CONTEXT)
+ .allMatch(value -> value.tokenUsage() == null),
+ "no other stage measures tokens, and a zero there would read as a measured zero");
+ }
+
@Test
void authorizationModelMismatchCannotReachTheVerifiedRenderer() {
CurrentActor actor = new CurrentActor(
@@ -741,6 +824,12 @@ private static LightRagGrounding grounding() {
private static LightRagGroundingAssembler.PreparedGrounding prepared(
LightRagGrounding grounding) {
+ return prepared(grounding, 0);
+ }
+
+ private static LightRagGroundingAssembler.PreparedGrounding prepared(
+ LightRagGrounding grounding,
+ int droppedContributions) {
List references =
grounding.evidenceClosure()
.stream()
@@ -756,7 +845,8 @@ private static LightRagGroundingAssembler.PreparedGrounding prepared(
"verified graph context",
"verified graph context\n\nWhat is the leave policy?",
references,
- 45);
+ 45,
+ droppedContributions);
}
private static EvidenceReference evidence(UUID chunkId) {
diff --git a/docs/increments/active/2026-07-29-observability-pipeline/plan.md b/docs/increments/active/2026-07-29-observability-pipeline/plan.md
index 694bc623..7182da51 100644
--- a/docs/increments/active/2026-07-29-observability-pipeline/plan.md
+++ b/docs/increments/active/2026-07-29-observability-pipeline/plan.md
@@ -91,8 +91,23 @@ Depends on the composite sink merged in PR #132.
coverage.
- [x] Tests: both sinks enabled receive the event; either toggle leaves the
other; both disabled compose to `NO_OP`.
-- [ ] Export `ContextTokenUsage`, which core already computes and nothing
- publishes, plus a counter for `finish_reason=length` truncation.
+- [x] Export `ContextTokenUsage`, which core already computes and nothing
+ publishes. The event carries the rendered prompt size, the ceiling it was
+ fitted to and the per-channel breakdown; meters accumulate tokens by
+ channel and summarise the prompt size, so headroom is readable before
+ truncation starts rather than after.
+- [x] Report input-side truncation. The assembler evicts contributions to fit
+ the budget in two places — the per-channel allocator and the total-budget
+ loop — and neither reported anything, so an answer cut down to fit looked
+ exactly like a whole one. `PreparedGrounding.droppedContributions` now
+ counts both, measured after merging so deduplication is not mistaken for
+ eviction, and meters count both how much context was refused and how many
+ answers were affected.
+- [ ] Counter for `finish_reason=length`. This is output-side truncation and the
+ chat port cannot see it: `ChatModelPort` streams `Flux` and the
+ adapter calls `.stream().content()`, which discards the `ChatResponse`
+ holding the finish reason. It lands with `GENERATE` below, where the port
+ change is already required.
- [ ] Time to first token on the streaming assistant path.
- [ ] Close the stage gap the LightRAG comparison surfaced. `Stage` declares
fourteen and production emits ten: `PARSE` and `CHUNK` need a sink in the
@@ -106,9 +121,16 @@ one from a series that already exists: organization and operation identifiers,
fingerprints and durations are not tags. Each would grow the stored series count
with tenants or requests. They stay on the span, where each is one record rather
than a permanent series. `failureCode` tags only the failure counter, never the
-timer, because the port bounds its shape and not its set of values. The design's
-"tokens by organization" dashboard therefore needs its own decision when the
-token metrics land.
+timer, because the port bounds its shape and not its set of values.
+
+The design's "tokens by organization" dashboard is settled by the same rule and
+does not get an exception: token counts are not tagged by organization. One
+series per tenant per channel grows for as long as the product sells, and the
+cost is paid by the metrics backend forever rather than by the request that
+created it. Per-organization attribution stays on the span, which already
+carries the identifier, or belongs to a billing record — a product feature
+rather than a side effect of telemetry. The dashboard the design asked for is
+therefore a span query, not a meter.
Not done, with a reason: publishing
`FailureTolerantGraphRagEventSink.swallowedFailureCount()` as a gauge. The
diff --git a/docs/specs/domains/secure-graph-rag.md b/docs/specs/domains/secure-graph-rag.md
index 23c708b8..26a7c53a 100644
--- a/docs/specs/domains/secure-graph-rag.md
+++ b/docs/specs/domains/secure-graph-rag.md
@@ -157,6 +157,19 @@ Reconciled: `2026-07-29-observability-pipeline (de29c9e)`.
rate. Meter tags are restricted to bounded enumerations — stage, outcome, cache
status, and failure code on the failure counter only. Organization and
operation identifiers and fingerprints are not tags; they stay on the span.
+- Context assembly reports the token cost of one answer and the context the
+ budget refused to carry: the rendered prompt size, the ceiling it was fitted
+ to, a breakdown across system prompt, query, entity, relation and chunk, and
+ how many merged contributions were evicted to make it fit. Counts only — a
+ number cannot reconstruct the text it measured. No other stage carries the
+ measurement, and its absence means the stage took none rather than measured
+ zero. Deduplication is not eviction: merging two copies of one grounding
+ reports nothing dropped, because the model still sees everything retrieval
+ selected. Meters accumulate tokens by channel, summarise the prompt size so
+ headroom is visible before truncation starts, and count evictions two ways —
+ the number of contributions refused and the number of answers affected, which
+ no sum of the first can recover. Tokens are not tagged by organization; that
+ attribution stays on the span, which already carries the identifier.
- Indexing and retrieval fan one stage event out to every registered
`GraphRagEventSink`, so an application may observe the same stage through more
than one backend. Sinks fail independently and emission never controls
diff --git a/docs/tests/domains/secure-graph-rag.md b/docs/tests/domains/secure-graph-rag.md
index e22d8824..f03ea370 100644
--- a/docs/tests/domains/secure-graph-rag.md
+++ b/docs/tests/domains/secure-graph-rag.md
@@ -55,6 +55,18 @@ Reconciled: `2026-07-29-observability-pipeline (de29c9e)`.
cache status becomes its own value rather than a dropped tag, that failures
count by code while the code stays off the timer, and that no meter carries an
organization, operation or fingerprint tag.
+- Context token tests prove the breakdown reaches the event with the ceiling it
+ was fitted to, that no other stage carries one, that meters accumulate tokens
+ by channel and separate how much context was refused from how many answers
+ were affected, and that neither truncation counter moves when the context
+ fitted. The span assertion checks the exact key set and then that every added
+ key is numeric — a substring guard would reject `query_tokens` for its name
+ while a type check proves a count cannot hold the text it measured.
+- Assembler budget tests prove a budget too small for the grounding reports what
+ it evicted, that the reported number is exactly the gap between what retrieval
+ selected and what the model saw, and that consolidating two copies of one
+ grounding reports nothing dropped, so deduplication cannot be mistaken for
+ truncation.
- Observability wiring tests prove both backends are contributed together, that
either toggle leaves the other in place, and that with both disabled the
remaining sinks compose to `NO_OP`.
diff --git a/integrations/graph-rag-observability/src/main/java/com/orgmemory/integrations/graphrag/observability/MicrometerGraphRagEventSink.java b/integrations/graph-rag-observability/src/main/java/com/orgmemory/integrations/graphrag/observability/MicrometerGraphRagEventSink.java
index a3979c55..9acc0e4e 100644
--- a/integrations/graph-rag-observability/src/main/java/com/orgmemory/integrations/graphrag/observability/MicrometerGraphRagEventSink.java
+++ b/integrations/graph-rag-observability/src/main/java/com/orgmemory/integrations/graphrag/observability/MicrometerGraphRagEventSink.java
@@ -22,6 +22,13 @@
* the span, where each is one record rather than a permanent series. That is also why
* {@code failureCode} — bounded by the port's own {@code [a-z0-9_]{1,64}} contract but not by a
* closed enum — tags only the failure counter and never the timer.
+ *
+ * The same rule settles a question the design left open when it asked for a "tokens by
+ * organization" board. Token counts are not tagged by organization here. One series per tenant
+ * per channel grows without bound as the product sells, and the cost of an unbounded tag is paid
+ * by the metrics backend forever rather than by the request that created it. Per-organization
+ * attribution belongs to the span, which already carries the organization identifier, or to a
+ * billing record, which is a product feature and not a side effect of telemetry.
*/
public final class MicrometerGraphRagEventSink implements GraphRagEventSink {
@@ -29,6 +36,12 @@ public final class MicrometerGraphRagEventSink implements GraphRagEventSink {
static final String FAILURE_COUNTER = "orgmemory.graph_rag.stage.failures";
static final String INPUT_COUNTER = "orgmemory.graph_rag.stage.inputs";
static final String OUTPUT_COUNTER = "orgmemory.graph_rag.stage.outputs";
+ static final String CONTEXT_TOKEN_COUNTER = "orgmemory.graph_rag.context.tokens";
+ static final String CONTEXT_PROMPT_TOKENS = "orgmemory.graph_rag.context.prompt_tokens";
+ static final String CONTEXT_DROPPED_COUNTER =
+ "orgmemory.graph_rag.context.dropped_contributions";
+ static final String CONTEXT_TRUNCATION_COUNTER =
+ "orgmemory.graph_rag.context.truncations";
private final MeterRegistry registry;
@@ -60,6 +73,36 @@ public void emit(GraphRagEvent event) {
"failure_code", event.failureCode()))
.increment();
}
+
+ if (event.tokenUsage() != null) {
+ recordTokenUsage(event.tokenUsage());
+ }
+ }
+
+ private void recordTokenUsage(TokenUsage usage) {
+ countTokens("system_prompt", usage.systemPromptTokens());
+ countTokens("query", usage.queryTokens());
+ countTokens("entity", usage.entityTokens());
+ countTokens("relation", usage.relationTokens());
+ countTokens("chunk", usage.chunkTokens());
+ // Summarised rather than counted, unlike the channels above: the channel totals answer
+ // what the deployment spends, while the size of one assembled prompt is a per-request
+ // shape, and a p95 close to the budget is the warning that arrives before truncation
+ // starts rather than after.
+ registry.summary(CONTEXT_PROMPT_TOKENS).record(usage.promptTokens());
+
+ // Two meters because they answer two questions a single one cannot. The dropped counter
+ // says how much context the budget refused; the truncation counter says how many answers
+ // were affected at all, which no sum of dropped items can recover.
+ if (usage.truncated()) {
+ registry.counter(CONTEXT_DROPPED_COUNTER)
+ .increment(usage.droppedContributions());
+ registry.counter(CONTEXT_TRUNCATION_COUNTER).increment();
+ }
+ }
+
+ private void countTokens(String channel, int tokens) {
+ registry.counter(CONTEXT_TOKEN_COUNTER, "channel", channel).increment(tokens);
}
private static String enumValue(Enum> value) {
diff --git a/integrations/graph-rag-observability/src/main/java/com/orgmemory/integrations/graphrag/observability/OpenTelemetryGraphRagEventSink.java b/integrations/graph-rag-observability/src/main/java/com/orgmemory/integrations/graphrag/observability/OpenTelemetryGraphRagEventSink.java
index cd2407e6..2c71220d 100644
--- a/integrations/graph-rag-observability/src/main/java/com/orgmemory/integrations/graphrag/observability/OpenTelemetryGraphRagEventSink.java
+++ b/integrations/graph-rag-observability/src/main/java/com/orgmemory/integrations/graphrag/observability/OpenTelemetryGraphRagEventSink.java
@@ -43,6 +43,22 @@ public final class OpenTelemetryGraphRagEventSink implements GraphRagEventSink {
AttributeKey.stringKey("orgmemory.graph_rag.cache_status");
static final AttributeKey FAILURE_CODE =
AttributeKey.stringKey("orgmemory.graph_rag.failure_code");
+ static final AttributeKey PROMPT_TOKENS =
+ AttributeKey.longKey("orgmemory.graph_rag.prompt_tokens");
+ static final AttributeKey SYSTEM_PROMPT_TOKENS =
+ AttributeKey.longKey("orgmemory.graph_rag.system_prompt_tokens");
+ static final AttributeKey QUERY_TOKENS =
+ AttributeKey.longKey("orgmemory.graph_rag.query_tokens");
+ static final AttributeKey ENTITY_TOKENS =
+ AttributeKey.longKey("orgmemory.graph_rag.entity_tokens");
+ static final AttributeKey RELATION_TOKENS =
+ AttributeKey.longKey("orgmemory.graph_rag.relation_tokens");
+ static final AttributeKey CHUNK_TOKENS =
+ AttributeKey.longKey("orgmemory.graph_rag.chunk_tokens");
+ static final AttributeKey BUDGET_TOKENS =
+ AttributeKey.longKey("orgmemory.graph_rag.budget_tokens");
+ static final AttributeKey DROPPED_CONTRIBUTIONS =
+ AttributeKey.longKey("orgmemory.graph_rag.dropped_contributions");
private final Tracer tracer;
@@ -85,12 +101,31 @@ public void emit(GraphRagEvent event) {
if (event.failureCode() != null) {
span.setAttribute(FAILURE_CODE, event.failureCode());
}
+ if (event.tokenUsage() != null) {
+ recordTokenUsage(span, event.tokenUsage());
+ }
if (event.outcome() == Outcome.FAILED) {
span.setStatus(StatusCode.ERROR);
}
span.end(endEpochNanos, TimeUnit.NANOSECONDS);
}
+ /**
+ * The span carries the whole breakdown because a span is one record rather
+ * than a permanent series, so the per-request detail that would be reckless
+ * as metric dimensions is affordable here.
+ */
+ private static void recordTokenUsage(Span span, TokenUsage usage) {
+ span.setAttribute(PROMPT_TOKENS, usage.promptTokens());
+ span.setAttribute(SYSTEM_PROMPT_TOKENS, usage.systemPromptTokens());
+ span.setAttribute(QUERY_TOKENS, usage.queryTokens());
+ span.setAttribute(ENTITY_TOKENS, usage.entityTokens());
+ span.setAttribute(RELATION_TOKENS, usage.relationTokens());
+ span.setAttribute(CHUNK_TOKENS, usage.chunkTokens());
+ span.setAttribute(BUDGET_TOKENS, usage.budgetTokens());
+ span.setAttribute(DROPPED_CONTRIBUTIONS, usage.droppedContributions());
+ }
+
private static String spanName(GraphRagEvent event) {
return "orgmemory.graph_rag." + enumValue(event.stage());
}
diff --git a/integrations/graph-rag-observability/src/test/java/com/orgmemory/integrations/graphrag/observability/MicrometerGraphRagEventSinkTests.java b/integrations/graph-rag-observability/src/test/java/com/orgmemory/integrations/graphrag/observability/MicrometerGraphRagEventSinkTests.java
index 475e6f67..bbea90a9 100644
--- a/integrations/graph-rag-observability/src/test/java/com/orgmemory/integrations/graphrag/observability/MicrometerGraphRagEventSinkTests.java
+++ b/integrations/graph-rag-observability/src/test/java/com/orgmemory/integrations/graphrag/observability/MicrometerGraphRagEventSinkTests.java
@@ -51,6 +51,9 @@ void separatesStagesOutcomesAndCacheStatusSoLatencyCanBeAttributed() {
@Test
void carriesNoIdentifierThatWouldGrowASeriesPerTenantOrRequest() {
sink.emit(event(GraphRagEventSink.Outcome.SUCCEEDED, null, Duration.ofMillis(10)));
+ // A meter this guard never instantiates is a meter it never guards, and the token
+ // meters are the ones a per-organization tag would be most tempting on.
+ sink.emit(assembledContext(usage(2)));
Set forbidden = Set.of(
"organization_id", "operation_id", "scope_fingerprint", "model_route_fingerprint");
@@ -104,6 +107,68 @@ void reportsAnAbsentCacheStatusAsItsOwnValueRatherThanDroppingTheTag() {
"a missing tag would silently merge cached and uncached stages into one series");
}
+ @Test
+ void accumulatesContextTokensByChannelSoPromptCostIsAttributable() {
+ sink.emit(assembledContext(usage(0)));
+
+ assertEquals(
+ 30.0,
+ registry.get(MicrometerGraphRagEventSink.CONTEXT_TOKEN_COUNTER)
+ .tag("channel", "system_prompt")
+ .counter()
+ .count());
+ assertEquals(
+ 900.0,
+ registry.get(MicrometerGraphRagEventSink.CONTEXT_TOKEN_COUNTER)
+ .tag("channel", "chunk")
+ .counter()
+ .count(),
+ "chunks are the channel that grows, so it has to be separable from the rest");
+ }
+
+ @Test
+ void summarisesThePromptSizeSoHeadroomIsVisibleBeforeTruncationStarts() {
+ sink.emit(assembledContext(usage(0)));
+
+ assertEquals(
+ 1_400.0,
+ registry.get(MicrometerGraphRagEventSink.CONTEXT_PROMPT_TOKENS).summary().totalAmount(),
+ "the rendered prompt, not the sum of the channels, is what the model is charged for");
+ }
+
+ @Test
+ void countsBothHowOftenContextWasDroppedAndHowMuch() {
+ sink.emit(assembledContext(usage(3)));
+ sink.emit(assembledContext(usage(5)));
+
+ assertEquals(
+ 8.0,
+ registry.get(MicrometerGraphRagEventSink.CONTEXT_DROPPED_COUNTER).counter().count(),
+ "how much context the budget refused");
+ assertEquals(
+ 2.0,
+ registry.get(MicrometerGraphRagEventSink.CONTEXT_TRUNCATION_COUNTER).counter().count(),
+ "how many answers were affected, which no sum of dropped items can recover");
+ }
+
+ @Test
+ void leavesTheTruncationCountersUntouchedWhenTheContextFitted() {
+ sink.emit(assembledContext(usage(0)));
+
+ assertNull(
+ registry.find(MicrometerGraphRagEventSink.CONTEXT_TRUNCATION_COUNTER).counter(),
+ "a counter that ticks on every untruncated request cannot report a truncation rate");
+ assertNull(registry.find(MicrometerGraphRagEventSink.CONTEXT_DROPPED_COUNTER).counter());
+ }
+
+ @Test
+ void recordsNoTokenMetersForAStageThatMeasuresNone() {
+ sink.emit(event(GraphRagEventSink.Outcome.SUCCEEDED, null, Duration.ofMillis(1)));
+
+ assertNull(registry.find(MicrometerGraphRagEventSink.CONTEXT_TOKEN_COUNTER).counter());
+ assertNull(registry.find(MicrometerGraphRagEventSink.CONTEXT_PROMPT_TOKENS).summary());
+ }
+
private Set tagKeysOf(String meterName) {
List tags = registry.get(meterName).timer().getId().getTags();
return tags.stream().map(Tag::getKey).collect(Collectors.toSet());
@@ -125,4 +190,27 @@ private static GraphRagEventSink.GraphRagEvent event(
failureCode,
Instant.now());
}
+
+ private static GraphRagEventSink.GraphRagEvent assembledContext(
+ GraphRagEventSink.TokenUsage usage) {
+ return new GraphRagEventSink.GraphRagEvent(
+ UUID.randomUUID(),
+ UUID.randomUUID(),
+ GraphRagEventSink.Stage.ASSEMBLE_CONTEXT,
+ GraphRagEventSink.Outcome.SUCCEEDED,
+ Duration.ofMillis(12),
+ 4,
+ 2,
+ null,
+ null,
+ null,
+ null,
+ usage,
+ Instant.now());
+ }
+
+ private static GraphRagEventSink.TokenUsage usage(int droppedContributions) {
+ return new GraphRagEventSink.TokenUsage(
+ 1_400, 30, 12, 220, 180, 900, 29_800, droppedContributions);
+ }
}
diff --git a/integrations/graph-rag-observability/src/test/java/com/orgmemory/integrations/graphrag/observability/OpenTelemetryGraphRagEventSinkTests.java b/integrations/graph-rag-observability/src/test/java/com/orgmemory/integrations/graphrag/observability/OpenTelemetryGraphRagEventSinkTests.java
index 3b3f8418..38d075b6 100644
--- a/integrations/graph-rag-observability/src/test/java/com/orgmemory/integrations/graphrag/observability/OpenTelemetryGraphRagEventSinkTests.java
+++ b/integrations/graph-rag-observability/src/test/java/com/orgmemory/integrations/graphrag/observability/OpenTelemetryGraphRagEventSinkTests.java
@@ -81,6 +81,108 @@ void exportsOnlyTheClosedPayloadFreeAttributeSetWithOriginalTiming() {
}
}
+ /**
+ * Context assembly adds attributes named after the channels this boundary refuses to carry —
+ * {@code query_tokens}, {@code system_prompt_tokens}. The names are not the danger and a
+ * substring check on them would be the wrong guard: what makes a count safe is that a number
+ * cannot reconstruct the text it measured. So this asserts the exact key set and then asserts
+ * every added key is numeric, which no amount of prompt text could satisfy.
+ */
+ @Test
+ void carriesTheContextTokenBreakdownAsNumbersThatCannotHoldTheTextTheyMeasure() {
+ InMemorySpanExporter exporter = InMemorySpanExporter.create();
+ try (SdkTracerProvider provider = SdkTracerProvider.builder()
+ .addSpanProcessor(SimpleSpanProcessor.create(exporter))
+ .build()) {
+ var telemetry = OpenTelemetrySdk.builder()
+ .setTracerProvider(provider)
+ .build();
+ var sink = new OpenTelemetryGraphRagEventSink(telemetry);
+
+ sink.emit(new GraphRagEventSink.GraphRagEvent(
+ UUID.randomUUID(),
+ UUID.randomUUID(),
+ GraphRagEventSink.Stage.ASSEMBLE_CONTEXT,
+ GraphRagEventSink.Outcome.SUCCEEDED,
+ Duration.ofMillis(12),
+ 2,
+ 7,
+ null,
+ null,
+ null,
+ null,
+ new GraphRagEventSink.TokenUsage(
+ 1_400, 30, 12, 220, 180, 900, 29_800, 3),
+ Instant.parse("2026-07-30T09:00:00Z")));
+
+ var span = exporter.getFinishedSpanItems().getFirst();
+ assertEquals(Set.of(
+ "orgmemory.graph_rag.operation_id",
+ "orgmemory.graph_rag.organization_id",
+ "orgmemory.graph_rag.stage",
+ "orgmemory.graph_rag.outcome",
+ "orgmemory.graph_rag.duration_nanos",
+ "orgmemory.graph_rag.input_count",
+ "orgmemory.graph_rag.output_count",
+ "orgmemory.graph_rag.prompt_tokens",
+ "orgmemory.graph_rag.system_prompt_tokens",
+ "orgmemory.graph_rag.query_tokens",
+ "orgmemory.graph_rag.entity_tokens",
+ "orgmemory.graph_rag.relation_tokens",
+ "orgmemory.graph_rag.chunk_tokens",
+ "orgmemory.graph_rag.budget_tokens",
+ "orgmemory.graph_rag.dropped_contributions"),
+ span.getAttributes().asMap().keySet().stream()
+ .map(io.opentelemetry.api.common.AttributeKey::getKey)
+ .collect(Collectors.toSet()));
+ span.getAttributes().forEach((key, value) -> {
+ if (key.getKey().endsWith("_tokens")
+ || key.getKey().endsWith("dropped_contributions")) {
+ assertEquals(
+ io.opentelemetry.api.common.AttributeType.LONG,
+ key.getType(),
+ () -> key.getKey() + " must be a count, not text");
+ }
+ });
+ assertEquals(1_400L, span.getAttributes().get(
+ OpenTelemetryGraphRagEventSink.PROMPT_TOKENS));
+ assertEquals(3L, span.getAttributes().get(
+ OpenTelemetryGraphRagEventSink.DROPPED_CONTRIBUTIONS));
+ }
+ }
+
+ @Test
+ void omitsTheTokenAttributesForAStageThatMeasuresNone() {
+ InMemorySpanExporter exporter = InMemorySpanExporter.create();
+ try (SdkTracerProvider provider = SdkTracerProvider.builder()
+ .addSpanProcessor(SimpleSpanProcessor.create(exporter))
+ .build()) {
+ var telemetry = OpenTelemetrySdk.builder()
+ .setTracerProvider(provider)
+ .build();
+ new OpenTelemetryGraphRagEventSink(telemetry).emit(
+ new GraphRagEventSink.GraphRagEvent(
+ UUID.randomUUID(),
+ UUID.randomUUID(),
+ GraphRagEventSink.Stage.EMBED,
+ GraphRagEventSink.Outcome.SUCCEEDED,
+ Duration.ofMillis(4),
+ 1,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ Instant.parse("2026-07-30T09:00:00Z")));
+
+ var span = exporter.getFinishedSpanItems().getFirst();
+ assertFalse(
+ span.getAttributes().asMap().keySet().stream()
+ .anyMatch(key -> key.getKey().endsWith("_tokens")),
+ "a zero token count would read as a measured zero rather than as no measurement");
+ }
+ }
+
private static long epochNanos(Instant instant) {
return Math.addExact(
Math.multiplyExact(instant.getEpochSecond(), 1_000_000_000L),