Collect PromptKit execution observations with logical attribution

This commit is contained in:
2026-09-24 02:23:57 +00:00
parent 4812032c9e
commit 4bfc5f3456
11 changed files with 457 additions and 14 deletions

View File

@@ -30,5 +30,9 @@ PromptKit's per-backend admission applies to both roles and every generation,
retry, and repair. The runner still processes module instances in order, retry, and repair. The runner still processes module instances in order,
collects concurrent section work deterministically, and cancels remaining collects concurrent section work deterministically, and cancels remaining
section work on a runtime failure. `llm.DiagnosticsWriter` remains the protected section work on a runtime failure. `llm.DiagnosticsWriter` remains the protected
request/response artifact writer. Execution observations and report summaries request/response artifact writer. `llm.ObservationCollector` captures metadata
are completed by the following roadmap work. events under a short mutex and freezes them after runner work joins. Its logical
records include preparation failures; summary usage comes from terminal events
once per call and retains knownness and completeness for each field. Runner wall
and validator timings remain separate from provider attempts. The report and
artifact mapping is completed by the following roadmap work.

View File

@@ -429,7 +429,16 @@ or add placeholders merely to make this intermediate commit build.
## Stage 9: Collect Execution Observations Safely ## Stage 9: Collect Execution Observations Safely
**Status:** Pending. **Depends on:** Stage 8. **Status:** Complete (2026-09-24). Added Audita-owned logical-call and execution
event records with call IDs, diagnostic attribution, short synchronized capture,
copying snapshots, and terminal-only usage aggregation. Preserved runner wall
and per-validator timings. Tests cover preparation failure, undispatched
admission, known zero versus unknown usage, partial failed repairs, no double
counting, concurrent attribution, and the real role-client hook. `go test
-race ./internal/framework/llm ./internal/framework/runner`, focused package
tests, and `go build -o /tmp/audita-stage9-build ./cmd/audita` passed;
`git diff --check` passed. Report/artifact serialization follows in Stage 10.
**Depends on:** Stage 8.
**Owns:** Audita-owned execution record types and collector in the LLM boundary, **Owns:** Audita-owned execution record types and collector in the LLM boundary,
runner attribution, and concurrency/observation tests. runner attribution, and concurrency/observation tests.

View File

@@ -147,6 +147,7 @@ var processRunner = func(inv processInvocation, stdout io.Writer) (*normalizatio
workingTranscript := normalizedTranscript workingTranscript := normalizedTranscript
var runOutput *runner.RunOutput var runOutput *runner.RunOutput
observations := llm.NewObservationCollector()
moduleFactory := processModuleFactory moduleFactory := processModuleFactory
if moduleFactory == nil { if moduleFactory == nil {
moduleFactory = modules.NewFactory(modules.Dependencies{ moduleFactory = modules.NewFactory(modules.Dependencies{
@@ -162,13 +163,13 @@ var processRunner = func(inv processInvocation, stdout io.Writer) (*normalizatio
return fail("runner_setup", fmt.Errorf("LLM runtime is required"), nil) return fail("runner_setup", fmt.Errorf("LLM runtime is required"), nil)
} }
if proposalLLMClient == nil { if proposalLLMClient == nil {
proposalLLMClient, err = inv.LLMRuntime.NewRoleClient("proposal", llm.CallHooks{}) proposalLLMClient, err = inv.LLMRuntime.NewRoleClient("proposal", observations.Hooks())
if err != nil { if err != nil {
return fail("runner_setup", err, nil) return fail("runner_setup", err, nil)
} }
} }
if validationLLMClient == nil { if validationLLMClient == nil {
validationLLMClient, err = inv.LLMRuntime.NewRoleClient("validation", llm.CallHooks{}) validationLLMClient, err = inv.LLMRuntime.NewRoleClient("validation", observations.Hooks())
if err != nil { if err != nil {
return fail("runner_setup", err, nil) return fail("runner_setup", err, nil)
} }
@@ -192,6 +193,8 @@ var processRunner = func(inv processInvocation, stdout io.Writer) (*normalizatio
ValidationLLMClient: validationLLMClient, ValidationLLMClient: validationLLMClient,
ValidationDiagnosticsDir: runDir.Path(), ValidationDiagnosticsDir: runDir.Path(),
}) })
runnerResult.Observations = observations.Snapshot()
runnerResult.LLMSummary = llm.Summarize(runnerResult.Observations)
runOutput = &runnerResult runOutput = &runnerResult
if runErr != nil { if runErr != nil {
return fail("runner_execution", runErr, runOutput) return fail("runner_execution", runErr, runOutput)

View File

@@ -16,8 +16,9 @@ import (
// CallHooks receive one logical start and finish record and the execution // CallHooks receive one logical start and finish record and the execution
// events for this call. Call records can contain transcript content. // events for this call. Call records can contain transcript content.
type CallHooks struct { type CallHooks struct {
OnCall func(structuredoutput.CallRecord) OnCall func(structuredoutput.CallRecord)
OnEvent pk.ExecutionObserver OnEvent pk.ExecutionObserver
OnEventWithCall func(uint64, structuredoutput.DiagnosticContext, pk.ExecutionEvent)
} }
type RoleClient struct { type RoleClient struct {
@@ -38,7 +39,7 @@ func (r *Runtime) NewRoleClient(role string, hooks CallHooks) (*RoleClient, erro
func (c *RoleClient) CompleteStructured(parent context.Context, req structuredoutput.Request, out any) (response structuredoutput.Response, err error) { func (c *RoleClient) CompleteStructured(parent context.Context, req structuredoutput.Request, out any) (response structuredoutput.Response, err error) {
ctx, cancel := context.WithTimeout(parent, c.runtime.Config.LLM.OperationTimeout) ctx, cancel := context.WithTimeout(parent, c.runtime.Config.LLM.OperationTimeout)
defer cancel() defer cancel()
record := structuredoutput.CallRecord{Context: req.Context, Started: time.Now().UTC()} record := structuredoutput.CallRecord{CallID: c.runtime.nextCallID.Add(1), Context: copyContext(req.Context), Started: time.Now().UTC()}
record.Context.Role = c.role record.Context.Role = c.role
if c.hooks.OnCall != nil { if c.hooks.OnCall != nil {
c.hooks.OnCall(record) c.hooks.OnCall(record)
@@ -78,7 +79,14 @@ func (c *RoleClient) CompleteStructured(parent context.Context, req structuredou
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptID: req.PromptID, PromptVersion: req.PromptVersion,
ProfileID: profile.ProfileID, APIKey: apiKey, Vars: vars, ProfileID: profile.ProfileID, APIKey: apiKey, Vars: vars,
Validation: &pk.OutputContract{Format: pk.FormatJSON, ValidationMode: pk.ValidationJSONSchema, SchemaPath: schemaPath, RepairAttempts: c.runtime.Config.LLM.RepairAttempts}, Validation: &pk.OutputContract{Format: pk.FormatJSON, ValidationMode: pk.ValidationJSONSchema, SchemaPath: schemaPath, RepairAttempts: c.runtime.Config.LLM.RepairAttempts},
Observer: c.hooks.OnEvent, Observer: func(event pk.ExecutionEvent) {
if c.hooks.OnEvent != nil {
c.hooks.OnEvent(event)
}
if c.hooks.OnEventWithCall != nil {
c.hooks.OnEventWithCall(record.CallID, record.Context, event)
}
},
}) })
if prepErr != nil { if prepErr != nil {
return response, &structuredoutput.Failure{Kind: structuredoutput.FailureRuntime, Message: "could not prepare structured model call", Cause: prepErr} return response, &structuredoutput.Failure{Kind: structuredoutput.FailureRuntime, Message: "could not prepare structured model call", Cause: prepErr}

View File

@@ -0,0 +1,209 @@
package llm
import (
"sort"
"sync"
"time"
"gitea.maximumdirect.net/eric/audita/internal/framework/structuredoutput"
pk "gitea.maximumdirect.net/eric/promptkit"
)
// TokenCount retains the distinction between zero and unavailable usage.
type TokenCount struct {
Value int `json:"value"`
Known bool `json:"known"`
Complete bool `json:"complete"`
}
type Usage struct {
Prompt TokenCount `json:"prompt"`
Completion TokenCount `json:"completion"`
Total TokenCount `json:"total"`
Cached TokenCount `json:"cached"`
CacheWrite TokenCount `json:"cache_write"`
}
// ExecutionObservation contains execution metadata only. It never contains
// endpoint URLs, credentials, messages, provider errors, or generated text.
type ExecutionObservation struct {
CallID uint64 `json:"call_id"`
Context structuredoutput.DiagnosticContext `json:"context"`
Kind string `json:"kind"`
RunID string `json:"run_id,omitempty"`
Time time.Time `json:"time"`
BackendID string `json:"backend_id,omitempty"`
ProfileID string `json:"profile_id,omitempty"`
Model string `json:"model,omitempty"`
Generation uint64 `json:"generation,omitempty"`
Attempt uint64 `json:"attempt,omitempty"`
Dispatched bool `json:"dispatched"`
CapacityWait time.Duration `json:"capacity_wait_ns"`
GenerationDuration time.Duration `json:"generation_duration_ns"`
RetryDelay time.Duration `json:"retry_delay_ns"`
Duration time.Duration `json:"duration_ns"`
Outcome string `json:"outcome,omitempty"`
Failure string `json:"failure,omitempty"`
StatusCode int `json:"status_code,omitempty"`
RetriesExhausted bool `json:"retries_exhausted,omitempty"`
Attempts uint64 `json:"attempts"`
TransientRetries uint64 `json:"transient_retries"`
StructuralRepairs uint64 `json:"structural_repairs"`
Usage Usage `json:"usage"`
}
type LogicalCall struct {
CallID uint64 `json:"call_id"`
Context structuredoutput.DiagnosticContext `json:"context"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
WallDuration time.Duration `json:"wall_duration_ns"`
Failure structuredoutput.FailureKind `json:"failure,omitempty"`
}
type ObservationSnapshot struct {
Calls []LogicalCall `json:"calls"`
Events []ExecutionObservation `json:"events"`
}
type ExecutionSummary struct {
LogicalCalls uint64 `json:"logical_calls"`
ProviderAttempts uint64 `json:"provider_attempts"`
TransientRetries uint64 `json:"transient_retries"`
StructuralRepairs uint64 `json:"structural_repairs"`
Usage Usage `json:"usage"`
}
// ObservationCollector accepts short synchronous callbacks from parallel calls.
// Snapshot should be taken after the runner has joined active section work.
type ObservationCollector struct {
mu sync.Mutex
calls map[uint64]LogicalCall
events []ExecutionObservation
}
func NewObservationCollector() *ObservationCollector {
return &ObservationCollector{calls: make(map[uint64]LogicalCall)}
}
func (c *ObservationCollector) Hooks() CallHooks {
return CallHooks{OnCall: c.recordCall, OnEventWithCall: c.recordEvent}
}
func copyContext(value structuredoutput.DiagnosticContext) structuredoutput.DiagnosticContext {
if value.SectionIndex != nil {
index := *value.SectionIndex
value.SectionIndex = &index
}
if value.BatchIndex != nil {
index := *value.BatchIndex
value.BatchIndex = &index
}
return value
}
func (c *ObservationCollector) recordCall(record structuredoutput.CallRecord) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
call := LogicalCall{CallID: record.CallID, Context: copyContext(record.Context), Started: record.Started, Finished: record.Finished, Failure: record.Failure}
if !record.Finished.IsZero() {
call.WallDuration = record.Finished.Sub(record.Started)
}
c.calls[record.CallID] = call
}
func (c *ObservationCollector) recordEvent(callID uint64, context structuredoutput.DiagnosticContext, event pk.ExecutionEvent) {
if c == nil {
return
}
count := func(value pk.ObservedTokenCount) TokenCount {
return TokenCount{Value: value.Value, Known: value.Known, Complete: value.Complete}
}
observation := ExecutionObservation{
CallID: callID, Context: copyContext(context), Kind: string(event.Kind), RunID: event.RunID,
Time: event.Time, BackendID: event.BackendID, ProfileID: event.ProfileID, Model: event.Model,
Generation: event.Generation, Attempt: event.Attempt, Dispatched: event.Dispatched,
CapacityWait: event.CapacityWait, GenerationDuration: event.GenerationDuration, RetryDelay: event.RetryDelay, Duration: event.Duration,
Outcome: string(event.Outcome), Failure: string(event.Failure), StatusCode: event.StatusCode, RetriesExhausted: event.RetriesExhausted,
Attempts: event.Attempts, TransientRetries: event.TransientRetries, StructuralRepairs: event.RepairCalls,
Usage: Usage{Prompt: count(event.Usage.PromptTokens), Completion: count(event.Usage.CompletionTokens), Total: count(event.Usage.TotalTokens), Cached: count(event.Usage.CachedTokens), CacheWrite: count(event.Usage.CacheWriteTokens)},
}
c.mu.Lock()
c.events = append(c.events, observation)
c.mu.Unlock()
}
func (c *ObservationCollector) Snapshot() ObservationSnapshot {
if c == nil {
return ObservationSnapshot{}
}
c.mu.Lock()
defer c.mu.Unlock()
snapshot := ObservationSnapshot{Calls: make([]LogicalCall, 0, len(c.calls)), Events: make([]ExecutionObservation, len(c.events))}
for _, call := range c.calls {
call.Context = copyContext(call.Context)
snapshot.Calls = append(snapshot.Calls, call)
}
copy(snapshot.Events, c.events)
for i := range snapshot.Events {
snapshot.Events[i].Context = copyContext(snapshot.Events[i].Context)
}
sort.Slice(snapshot.Calls, func(i, j int) bool { return snapshot.Calls[i].CallID < snapshot.Calls[j].CallID })
sort.SliceStable(snapshot.Events, func(i, j int) bool {
if snapshot.Events[i].CallID != snapshot.Events[j].CallID {
return snapshot.Events[i].CallID < snapshot.Events[j].CallID
}
return snapshot.Events[i].Time.Before(snapshot.Events[j].Time)
})
return snapshot
}
// Summarize counts terminal usage once per logical call. PromptKit's terminal
// usage already aggregates its attempts, retries, and repairs.
func Summarize(snapshot ObservationSnapshot) ExecutionSummary {
result := ExecutionSummary{LogicalCalls: uint64(len(snapshot.Calls))}
terminal := make(map[uint64]ExecutionObservation)
for _, event := range snapshot.Events {
if event.Kind == string(pk.ExecutionFinished) {
terminal[event.CallID] = event
}
}
dispatched := false
aggregate := func(dst *TokenCount, source TokenCount) {
if source.Known {
dst.Value += source.Value
dst.Known = true
}
dst.Complete = dst.Complete && source.Complete
}
result.Usage = Usage{Prompt: TokenCount{Complete: true}, Completion: TokenCount{Complete: true}, Total: TokenCount{Complete: true}, Cached: TokenCount{Complete: true}, CacheWrite: TokenCount{Complete: true}}
for _, call := range snapshot.Calls {
event, ok := terminal[call.CallID]
if !ok {
continue
}
result.ProviderAttempts += event.Attempts
result.TransientRetries += event.TransientRetries
result.StructuralRepairs += event.StructuralRepairs
if event.Attempts == 0 {
continue
}
dispatched = true
aggregate(&result.Usage.Prompt, event.Usage.Prompt)
aggregate(&result.Usage.Completion, event.Usage.Completion)
aggregate(&result.Usage.Total, event.Usage.Total)
aggregate(&result.Usage.Cached, event.Usage.Cached)
aggregate(&result.Usage.CacheWrite, event.Usage.CacheWrite)
}
if !dispatched {
result.Usage.Prompt.Known = true
result.Usage.Completion.Known = true
result.Usage.Total.Known = true
result.Usage.Cached.Known = true
result.Usage.CacheWrite.Known = true
}
return result
}

View File

@@ -0,0 +1,126 @@
package llm
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"time"
"gitea.maximumdirect.net/eric/audita/internal/framework/responseschema"
"gitea.maximumdirect.net/eric/audita/internal/framework/structuredoutput"
pk "gitea.maximumdirect.net/eric/promptkit"
)
func TestObservationSummarySeparatesLogicalCallsAttemptsAndUsage(t *testing.T) {
collector := NewObservationCollector()
hooks := collector.Hooks()
now := time.Now().UTC()
call := func(id uint64) {
hooks.OnCall(structuredoutput.CallRecord{CallID: id, Context: structuredoutput.DiagnosticContext{Role: "proposal"}, Started: now})
hooks.OnCall(structuredoutput.CallRecord{CallID: id, Context: structuredoutput.DiagnosticContext{Role: "proposal"}, Started: now, Finished: now.Add(time.Second)})
}
call(1) // preparation failed before PromptKit emitted events
call(2) // capacity rejected without a dispatched attempt
hooks.OnEventWithCall(2, structuredoutput.DiagnosticContext{Role: "proposal"}, pk.ExecutionEvent{Kind: pk.ExecutionAttempt, Dispatched: false})
hooks.OnEventWithCall(2, structuredoutput.DiagnosticContext{Role: "proposal"}, pk.ExecutionEvent{Kind: pk.ExecutionFinished, Attempts: 0})
summary := Summarize(collector.Snapshot())
if summary.LogicalCalls != 2 || summary.ProviderAttempts != 0 || summary.Usage.Total != (TokenCount{Known: true, Complete: true}) {
t.Fatalf("zero-attempt accounting: %+v", summary)
}
call(3)
context := structuredoutput.DiagnosticContext{Role: "validation", ModuleInstance: "grammar", ValidatorName: "editorial_review"}
hooks.OnEventWithCall(3, context, pk.ExecutionEvent{Kind: pk.ExecutionAttempt, Dispatched: true, Usage: pk.ObservedTokenUsage{TotalTokens: pk.ObservedTokenCount{Value: 100, Known: true, Complete: true}}})
hooks.OnEventWithCall(3, context, pk.ExecutionEvent{Kind: pk.ExecutionFinished, Attempts: 3, TransientRetries: 1, RepairCalls: 1, Usage: pk.ObservedTokenUsage{
PromptTokens: pk.ObservedTokenCount{Value: 5, Known: true, Complete: false},
TotalTokens: pk.ObservedTokenCount{Value: 8, Known: true, Complete: false},
}})
call(4)
hooks.OnEventWithCall(4, context, pk.ExecutionEvent{Kind: pk.ExecutionFinished, Attempts: 1, Usage: pk.ObservedTokenUsage{PromptTokens: pk.ObservedTokenCount{Known: false, Complete: false}, TotalTokens: pk.ObservedTokenCount{Known: false, Complete: false}}})
snapshot := collector.Snapshot()
summary = Summarize(snapshot)
if summary.LogicalCalls != 4 || summary.ProviderAttempts != 4 || summary.TransientRetries != 1 || summary.StructuralRepairs != 1 || summary.Usage.Total.Value != 8 || !summary.Usage.Total.Known || summary.Usage.Total.Complete || summary.Usage.Prompt.Value != 5 || !summary.Usage.Prompt.Known || summary.Usage.Prompt.Complete || summary.Usage.Cached.Known || summary.Usage.Cached.Complete {
t.Fatalf("partial usage or double count: %+v", summary)
}
raw, err := json.Marshal(snapshot)
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"https://", "Bearer ", "raw_output", "messages", "api_key"} {
if strings.Contains(string(raw), forbidden) {
t.Fatalf("observation leaked payload field %q", forbidden)
}
}
}
func TestObservationCollectorConcurrentAttributionAndSnapshotCopy(t *testing.T) {
collector := NewObservationCollector()
hooks := collector.Hooks()
const calls = 64
var wg sync.WaitGroup
for i := 1; i <= calls; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
index := id
context := structuredoutput.DiagnosticContext{Role: "proposal", ModuleInstance: "grammar", SectionIndex: &index}
started := time.Now().UTC()
hooks.OnCall(structuredoutput.CallRecord{CallID: uint64(id), Context: context, Started: started})
hooks.OnEventWithCall(uint64(id), context, pk.ExecutionEvent{Kind: pk.ExecutionFinished, RunID: "run", Attempts: 1, Usage: pk.ObservedTokenUsage{TotalTokens: pk.ObservedTokenCount{Value: 1, Known: true, Complete: true}}})
hooks.OnCall(structuredoutput.CallRecord{CallID: uint64(id), Context: context, Started: started, Finished: time.Now().UTC()})
index = -1
}(i)
}
wg.Wait()
snapshot := collector.Snapshot()
if len(snapshot.Calls) != calls || len(snapshot.Events) != calls || Summarize(snapshot).ProviderAttempts != calls {
t.Fatalf("parallel records: calls=%d events=%d", len(snapshot.Calls), len(snapshot.Events))
}
for _, call := range snapshot.Calls {
if call.Context.SectionIndex == nil || *call.Context.SectionIndex != int(call.CallID) {
t.Fatalf("attribution lost: %+v", call)
}
}
*snapshot.Calls[0].Context.SectionIndex = -2
again := collector.Snapshot()
if *again.Calls[0].Context.SectionIndex == -2 {
t.Fatal("snapshot aliases collector context")
}
}
func TestRoleClientFeedsCollectorAcrossPreparationFailure(t *testing.T) {
runtime, count, _ := adapterFixture(t, []reply{{status: 200, content: `{"corrections":[]}`}}, 0, 0, nil)
collector := NewObservationCollector()
client, err := runtime.NewRoleClient("proposal", collector.Hooks())
if err != nil {
t.Fatal(err)
}
request := adapterRequest(responseschema.CorrectionSetKey)
request.Context.ModuleInstance = "glossary_1"
index := 3
request.Context.SectionIndex = &index
missing := request
missing.PromptID = "missing.prompt"
var output map[string]any
if _, err := client.CompleteStructured(context.Background(), missing, &output); err == nil {
t.Fatal("preparation error missing")
}
if _, err := client.CompleteStructured(context.Background(), request, &output); err != nil {
t.Fatal(err)
}
snapshot := collector.Snapshot()
summary := Summarize(snapshot)
if count.Load() != 1 || summary.LogicalCalls != 2 || summary.ProviderAttempts != 1 || len(snapshot.Events) == 0 {
t.Fatalf("collector accounting: %+v", summary)
}
for _, event := range snapshot.Events {
if event.Context.ModuleInstance != "glossary_1" || event.Context.SectionIndex == nil || *event.Context.SectionIndex != 3 || event.Context.Role != "proposal" {
t.Fatalf("event attribution: %+v", event)
}
}
if snapshot.Calls[0].Failure != structuredoutput.FailureRuntime || snapshot.Calls[1].Failure != "" {
t.Fatalf("logical failures: %+v", snapshot.Calls)
}
}

View File

@@ -9,6 +9,7 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"sync/atomic"
"gitea.maximumdirect.net/eric/audita/internal/core/config" "gitea.maximumdirect.net/eric/audita/internal/core/config"
"gitea.maximumdirect.net/eric/audita/internal/framework/responseschema" "gitea.maximumdirect.net/eric/audita/internal/framework/responseschema"
@@ -19,9 +20,10 @@ import (
// Runtime owns one PromptKit engine and the validated selections for both roles. // Runtime owns one PromptKit engine and the validated selections for both roles.
// The same engine is shared by the role-bound clients during processing. // The same engine is shared by the role-bound clients during processing.
type Runtime struct { type Runtime struct {
Config config.Config Config config.Config
Engine *pk.Engine Engine *pk.Engine
Profiles map[string]pk.ProfileInspection Profiles map[string]pk.ProfileInspection
nextCallID atomic.Uint64
} }
// AssetError indicates a broken embedded prompt or schema rather than an // AssetError indicates a broken embedded prompt or schema rather than an

View File

@@ -82,12 +82,17 @@ type RunInput struct {
ProposalDiagnosticsDir string ProposalDiagnosticsDir string
ValidationLLMClient contracts.StructuredLLMClient ValidationLLMClient contracts.StructuredLLMClient
ValidationDiagnosticsDir string ValidationDiagnosticsDir string
timings *validatorTimingCollector
} }
// RunOutput is the deterministic runner output. // RunOutput is the deterministic runner output.
type RunOutput struct { type RunOutput struct {
FinalTranscript *schema.Transcript `json:"-"` FinalTranscript *schema.Transcript `json:"-"`
ModuleResults []ModuleResult `json:"module_results"` ModuleResults []ModuleResult `json:"module_results"`
Observations llm.ObservationSnapshot `json:"-"`
LLMSummary llm.ExecutionSummary `json:"-"`
RunWallTimeMS int64 `json:"-"`
ValidatorTimings []ValidatorTiming `json:"-"`
} }
func New(factory ModuleFactory) *Runner { func New(factory ModuleFactory) *Runner {
@@ -95,6 +100,15 @@ func New(factory ModuleFactory) *Runner {
} }
func (r *Runner) Run(ctx context.Context, input RunInput) (RunOutput, error) { func (r *Runner) Run(ctx context.Context, input RunInput) (RunOutput, error) {
started := time.Now()
input.timings = newValidatorTimingCollector()
output, err := r.run(ctx, input)
output.RunWallTimeMS = time.Since(started).Milliseconds()
output.ValidatorTimings = input.timings.snapshot()
return output, err
}
func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
if r == nil || r.factory == nil { if r == nil || r.factory == nil {
return RunOutput{}, fmt.Errorf("runner module factory is required") return RunOutput{}, fmt.Errorf("runner module factory is required")
} }
@@ -151,6 +165,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (RunOutput, error) {
DiagnosticsDir: input.ProposalDiagnosticsDir, DiagnosticsDir: input.ProposalDiagnosticsDir,
ValidationClient: input.ValidationLLMClient, ValidationClient: input.ValidationLLMClient,
ValidationDiagnosticsDir: input.ValidationDiagnosticsDir, ValidationDiagnosticsDir: input.ValidationDiagnosticsDir,
Timings: input.timings,
}) })
if pipelineErr != nil { if pipelineErr != nil {
failed := ModuleResult{ failed := ModuleResult{
@@ -207,6 +222,7 @@ type collectSectionProposalsInput struct {
DiagnosticsDir string DiagnosticsDir string
ValidationClient contracts.StructuredLLMClient ValidationClient contracts.StructuredLLMClient
ValidationDiagnosticsDir string ValidationDiagnosticsDir string
Timings *validatorTimingCollector
} }
type sectionProposals struct { type sectionProposals struct {
@@ -378,6 +394,7 @@ func runModulePipeline(ctx context.Context, input collectSectionProposalsInput)
SectionEnriched: enriched, SectionEnriched: enriched,
ValidationLLMClient: input.ValidationClient, ValidationLLMClient: input.ValidationClient,
DiagnosticsDir: input.ValidationDiagnosticsDir, DiagnosticsDir: input.ValidationDiagnosticsDir,
Timings: input.Timings,
}) })
validationResults <- sectionValidationResult{ validationResults <- sectionValidationResult{
sectionPos: sectionPos, sectionPos: sectionPos,
@@ -477,6 +494,7 @@ type validateSectionCandidatesInput struct {
SectionEnriched []proposals.EnrichedCorrectionProposal SectionEnriched []proposals.EnrichedCorrectionProposal
ValidationLLMClient contracts.StructuredLLMClient ValidationLLMClient contracts.StructuredLLMClient
DiagnosticsDir string DiagnosticsDir string
Timings *validatorTimingCollector
} }
type validateSectionCandidatesResult struct { type validateSectionCandidatesResult struct {
@@ -503,6 +521,7 @@ func validateSectionCandidates(ctx context.Context, input validateSectionCandida
} }
} }
validatorStarted := time.Now()
vResult, err := validator.Validate(ctx, contracts.ValidationRequest{ vResult, err := validator.Validate(ctx, contracts.ValidationRequest{
WorkingTranscript: input.WorkingTranscript, WorkingTranscript: input.WorkingTranscript,
CandidateProposal: eligible, CandidateProposal: eligible,
@@ -514,6 +533,9 @@ func validateSectionCandidates(ctx context.Context, input validateSectionCandida
LLMClient: input.ValidationLLMClient, LLMClient: input.ValidationLLMClient,
DiagnosticsWriter: diagnosticsWriter, DiagnosticsWriter: diagnosticsWriter,
}) })
if input.Timings != nil {
input.Timings.record(input.Spec.ModuleKey, input.Spec.InstanceName, validator.Name(), validatormetadata.ClassOf(validator) == validatormetadata.ExecutionClassLLMBacked, time.Since(validatorStarted))
}
if err != nil { if err != nil {
return validateSectionCandidatesResult{ return validateSectionCandidatesResult{

View File

@@ -564,6 +564,9 @@ func TestRunnerValidatorApprovedProposalApplied(t *testing.T) {
if len(out.ModuleResults[0].ValidatorDecisions) != 1 { if len(out.ModuleResults[0].ValidatorDecisions) != 1 {
t.Fatalf("expected validator decisions recorded") t.Fatalf("expected validator decisions recorded")
} }
if len(out.ValidatorTimings) != 1 || out.ValidatorTimings[0].Calls != 1 || out.ValidatorTimings[0].ValidatorName != "allow" || out.RunWallTimeMS < 0 {
t.Fatalf("runner timings missing: %+v", out.ValidatorTimings)
}
} }
func TestRunnerValidatorRejectedProposalNotApplied(t *testing.T) { func TestRunnerValidatorRejectedProposalNotApplied(t *testing.T) {

View File

@@ -0,0 +1,56 @@
package runner
import (
"sort"
"sync"
"time"
)
// ValidatorTiming records domain validator work without treating a scheduler
// permit or provider attempt as a logical LLM call.
type ValidatorTiming struct {
ModuleKey string `json:"module_key"`
ModuleInstance string `json:"module_instance"`
ValidatorName string `json:"validator_name"`
LLMBacked bool `json:"llm_backed"`
Calls int `json:"calls"`
ElapsedMS int64 `json:"elapsed_ms"`
}
type validatorTimingCollector struct {
mu sync.Mutex
byKey map[string]*ValidatorTiming
}
func newValidatorTimingCollector() *validatorTimingCollector {
return &validatorTimingCollector{byKey: make(map[string]*ValidatorTiming)}
}
func (c *validatorTimingCollector) record(moduleKey, moduleInstance, validatorName string, llmBacked bool, elapsed time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
key := moduleInstance + "\x00" + validatorName
timing := c.byKey[key]
if timing == nil {
timing = &ValidatorTiming{ModuleKey: moduleKey, ModuleInstance: moduleInstance, ValidatorName: validatorName, LLMBacked: llmBacked}
c.byKey[key] = timing
}
timing.Calls++
timing.ElapsedMS += elapsed.Milliseconds()
}
func (c *validatorTimingCollector) snapshot() []ValidatorTiming {
c.mu.Lock()
defer c.mu.Unlock()
result := make([]ValidatorTiming, 0, len(c.byKey))
for _, timing := range c.byKey {
result = append(result, *timing)
}
sort.Slice(result, func(i, j int) bool {
if result[i].ModuleInstance != result[j].ModuleInstance {
return result[i].ModuleInstance < result[j].ModuleInstance
}
return result[i].ValidatorName < result[j].ValidatorName
})
return result
}

View File

@@ -66,6 +66,7 @@ type Message struct {
// CallRecord brackets one logical operation, including preparation. Content // CallRecord brackets one logical operation, including preparation. Content
// is present only when a model returned it; consumers must protect it. // is present only when a model returned it; consumers must protect it.
type CallRecord struct { type CallRecord struct {
CallID uint64 `json:"call_id"`
Context DiagnosticContext `json:"context"` Context DiagnosticContext `json:"context"`
Started time.Time `json:"started"` Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"` Finished time.Time `json:"finished,omitempty"`