Make debug evidence opt-in and retain all candidates

This commit is contained in:
2026-09-24 17:41:10 +00:00
parent a80eed4745
commit 87b8d9ad5c
10 changed files with 246 additions and 213 deletions

View File

@@ -28,6 +28,7 @@ type processInvocation struct {
GlossaryPath string
OutputPath string
ReportDir string
RunID string
Config config.Config
ConfigResult *ck.Result[config.Config]
LLMRuntime *llm.Runtime
@@ -37,46 +38,65 @@ type processInvocation struct {
ConfigVersion *int
}
var processRunner = func(ctx context.Context, inv processInvocation, stdout io.Writer) (*normalization.NormalizationSummary, *chunking.Summary, *runner.RunOutput, *diagnostics.RunDirectory, error) {
runDir, err := diagnostics.NewRunDirectory(inv.Config.Diagnostics.WorkDir, "always", llm.ConfiguredSecrets(&inv.Config)...)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("run_dir_creation: %w", err)
var processRunner = func(ctx context.Context, inv processInvocation, stdout, stderr io.Writer) (*normalization.NormalizationSummary, *chunking.Summary, *runner.RunOutput, *diagnostics.RunDirectory, error) {
var runDir *diagnostics.RunDirectory
if inv.Config.Diagnostics.Debug {
var err error
runDir, err = diagnostics.NewRunDirectory(inv.Config.Diagnostics.WorkDir, inv.RunID, llm.ConfiguredSecrets(&inv.Config)...)
if err != nil {
fmt.Fprintf(stderr, "audita process: debug directory: %s\n", llm.RedactError(err, inv.Config))
}
}
debugPath := ""
if runDir != nil {
debugPath = runDir.Path()
}
debugWrite := func(label string, write func(*diagnostics.RunDirectory) error) {
if runDir == nil {
return
}
if err := write(runDir); err != nil {
fmt.Fprintf(stderr, "audita process: debug %s: %s\n", label, llm.RedactError(err, inv.Config))
}
}
fail := func(phase string, err error, runOutput *runner.RunOutput) (*normalization.NormalizationSummary, *chunking.Summary, *runner.RunOutput, *diagnostics.RunDirectory, error) {
_ = runDir.WriteErrorLog(fmt.Sprintf("%s: %s", phase, llm.RedactError(err, inv.Config)))
debugWrite("error log", func(d *diagnostics.RunDirectory) error {
return d.WriteErrorLog(fmt.Sprintf("%s: %s", phase, llm.RedactError(err, inv.Config)))
})
return nil, nil, runOutput, runDir, fmt.Errorf("%s: %w", phase, err)
}
if err := runDir.WriteInvocationMetadata(diagnostics.InvocationMetadata{
Operation: "process",
TranscriptPath: inv.TranscriptPath,
GlossaryPath: inv.GlossaryPath,
OutputPath: inv.OutputPath,
ConfigPath: inv.ConfigPath,
ConfigSource: inv.ConfigSource,
Overlay: inv.Overlay,
ConfigVersion: inv.ConfigVersion,
TranscriptDescription: inv.Config.Context.Description,
Modules: append([]string(nil), inv.Config.Pipeline.Modules...),
}); err != nil {
_ = runDir.WriteErrorLog(fmt.Sprintf("invocation_metadata: %v", err))
}
if inv.ConfigResult != nil {
debugWrite("invocation", func(d *diagnostics.RunDirectory) error {
return d.WriteInvocationMetadata(diagnostics.InvocationMetadata{
Operation: "process",
TranscriptPath: inv.TranscriptPath,
GlossaryPath: inv.GlossaryPath,
OutputPath: inv.OutputPath,
ConfigPath: inv.ConfigPath,
ConfigSource: inv.ConfigSource,
Overlay: inv.Overlay,
ConfigVersion: inv.ConfigVersion,
TranscriptDescription: inv.Config.Context.Description,
Modules: append([]string(nil), inv.Config.Pipeline.Modules...),
})
})
if runDir != nil && inv.ConfigResult != nil {
var effective bytes.Buffer
if err := inv.ConfigResult.WriteEffective(&effective); err == nil {
err = runDir.WriteEffectiveConfig(effective.Bytes())
}
if err != nil {
_ = runDir.WriteErrorLog(fmt.Sprintf("effective_config: %v", err))
debugWrite("effective settings", func(d *diagnostics.RunDirectory) error { return d.WriteEffectiveConfig(effective.Bytes()) })
}
if origins, err := llm.ExplanationYAML(inv.ConfigResult); err == nil {
_ = runDir.WriteYAMLArtifact(diagnostics.ArtifactConfigOrigins, origins)
debugWrite("configuration origins", func(d *diagnostics.RunDirectory) error {
return d.WriteYAMLArtifact(diagnostics.ArtifactConfigOrigins, origins)
})
}
}
if inv.LLMRuntime != nil {
if runDir != nil && inv.LLMRuntime != nil {
if profiles, err := inv.LLMRuntime.SelectedProfilesYAML(); err == nil {
_ = runDir.WriteYAMLArtifact(diagnostics.ArtifactSelectedProfiles, profiles)
debugWrite("selected profiles", func(d *diagnostics.RunDirectory) error {
return d.WriteYAMLArtifact(diagnostics.ArtifactSelectedProfiles, profiles)
})
}
}
@@ -100,9 +120,9 @@ var processRunner = func(ctx context.Context, inv processInvocation, stdout io.W
return fail("glossary_schema", err, nil)
}
if err := runDir.WriteSourceTranscript(sourceTranscript, transcriptBytes); err != nil {
_ = runDir.WriteErrorLog(fmt.Sprintf("source_artifact: %v", err))
}
debugWrite("source transcript", func(d *diagnostics.RunDirectory) error {
return d.WriteSourceTranscript(sourceTranscript, transcriptBytes)
})
canonical := sourceToCanonicalTranscript(sourceTranscript)
normalizer := normalization.NewNormalizer(normalization.NormalizationConfig{
@@ -114,12 +134,8 @@ var processRunner = func(ctx context.Context, inv processInvocation, stdout io.W
normalizedTranscript, normSummary := normalizer.Normalize(canonical)
if err := runDir.WriteNormalizedTranscript(normalizedTranscript); err != nil {
_ = runDir.WriteErrorLog(fmt.Sprintf("normalized_artifact: %v", err))
}
if err := runDir.WriteNormalizationSummary(normSummary); err != nil {
_ = runDir.WriteErrorLog(fmt.Sprintf("normalization_summary: %v", err))
}
debugWrite("normalized transcript", func(d *diagnostics.RunDirectory) error { return d.WriteNormalizedTranscript(normalizedTranscript) })
debugWrite("normalization summary", func(d *diagnostics.RunDirectory) error { return d.WriteNormalizationSummary(normSummary) })
// Compute chunks after normalization
chunker := chunking.NewChunker(chunking.ChunkingConfig{
@@ -141,14 +157,12 @@ var processRunner = func(ctx context.Context, inv processInvocation, stdout io.W
chunkSummary := chunking.ComputeSummary(sections, chunkConfig)
chunkDetailedSummary := chunking.ComputeDetailedSummary(sections, chunkConfig)
if err := runDir.WriteChunkingSummary(&chunkDetailedSummary); err != nil {
_ = runDir.WriteErrorLog(fmt.Sprintf("chunking_summary: %v", err))
}
debugWrite("chunking summary", func(d *diagnostics.RunDirectory) error { return d.WriteChunkingSummary(&chunkDetailedSummary) })
workingTranscript := normalizedTranscript
var runOutput *runner.RunOutput
observations := llm.NewObservationCollector()
moduleFactory := modules.NewFactory(modules.Dependencies{Config: &inv.Config, Glossary: glossary, DiagnosticsDir: runDir.Path()})
moduleFactory := modules.NewFactory(modules.Dependencies{Config: &inv.Config, Glossary: glossary, DiagnosticsDir: debugPath})
if inv.LLMRuntime == nil {
return fail("runner_setup", fmt.Errorf("LLM runtime is required"), nil)
}
@@ -173,9 +187,10 @@ var processRunner = func(ctx context.Context, inv processInvocation, stdout io.W
ModuleSpecs: moduleSpecs,
ProposalLLMClient: proposalLLMClient,
ProposalDiagnosticsDir: runDir.Path(),
ProposalDiagnosticsDir: debugPath,
ValidationLLMClient: validationLLMClient,
ValidationDiagnosticsDir: runDir.Path(),
ValidationDiagnosticsDir: debugPath,
CaptureCandidates: runDir != nil,
})
runnerResult.Observations = observations.Snapshot()
runnerResult.ObservationsAvailable = true
@@ -311,26 +326,24 @@ func runProcess(ctx context.Context, args []string, stdout, stderr io.Writer) in
ConfigVersion: &version,
}
normSummary, chunkSummary, runOutput, runDir, runErr := processRunner(ctx, inv, stdout)
normSummary, chunkSummary, runOutput, runDir, runErr := processRunner(ctx, inv, stdout, stderr)
completedAt := time.Now().UTC()
if runErr != nil {
if runDir != nil && runOutput != nil {
writeExecutionArtifacts(runDir, runOutput, inv.Config)
writeExecutionArtifacts(runDir, runOutput, inv.Config, stderr)
_ = runDir.WriteJSONArtifact(diagnostics.ArtifactCorrectionLedger, processreport.BuildCorrectionLedger(processreport.CorrectionLedgerInput{
if debugErr := runDir.WriteJSONArtifact(diagnostics.ArtifactCorrectionLedger, processreport.BuildCorrectionLedger(processreport.CorrectionLedgerInput{
RunDirectoryPath: runDir.Path(),
RunOutput: runOutput,
}))
})); debugErr != nil {
fmt.Fprintf(stderr, "audita process: debug correction ledger: %s\n", llm.RedactError(debugErr, inv.Config))
}
}
errorPhase, errorMessage := extractErrorPhase(runErr)
errorMessage = llm.RedactError(fmt.Errorf("%s", errorMessage), inv.Config)
report := processreport.Build(processReportInput("failed", inv, runDir, startedAt, completedAt, errorMessage, errorPhase, nil, nil, runOutput))
if runDir != nil {
_ = runDir.WriteReport(report)
}
fmt.Fprintf(stderr, "audita process: %s\n", llm.RedactError(runErr, inv.Config))
if runDir != nil {
fmt.Fprintf(stderr, "audita process: diagnostics: %s\n", runDir.Path())
@@ -339,29 +352,31 @@ func runProcess(ctx context.Context, args []string, stdout, stderr io.Writer) in
}
if runDir != nil && runOutput != nil {
writeExecutionArtifacts(runDir, runOutput, inv.Config)
writeExecutionArtifacts(runDir, runOutput, inv.Config, stderr)
_ = runDir.WriteJSONArtifact(diagnostics.ArtifactCorrectionLedger, processreport.BuildCorrectionLedger(processreport.CorrectionLedgerInput{
if debugErr := runDir.WriteJSONArtifact(diagnostics.ArtifactCorrectionLedger, processreport.BuildCorrectionLedger(processreport.CorrectionLedgerInput{
RunDirectoryPath: runDir.Path(),
RunOutput: runOutput,
}))
})); debugErr != nil {
fmt.Fprintf(stderr, "audita process: debug correction ledger: %s\n", llm.RedactError(debugErr, inv.Config))
}
}
report := processreport.Build(processReportInput("success", inv, runDir, startedAt, completedAt, "", "", normSummary, chunkSummary, runOutput))
if runDir != nil {
_ = runDir.WriteReport(report)
}
return 0
}
func writeExecutionArtifacts(runDir *diagnostics.RunDirectory, output *runner.RunOutput, cfg config.Config) {
func writeExecutionArtifacts(runDir *diagnostics.RunDirectory, output *runner.RunOutput, cfg config.Config, stderr io.Writer) {
if !output.ObservationsAvailable {
return
}
_ = runDir.WriteJSONArtifact(diagnostics.ArtifactLLMExecution, processreport.BuildLLMExecution(output.Observations))
_ = runDir.WriteJSONArtifact(diagnostics.ArtifactUtilizationSummary, processreport.BuildUtilization(output, cfg.Backends))
if err := runDir.WriteJSONArtifact(diagnostics.ArtifactLLMExecution, processreport.BuildLLMExecution(output.Observations)); err != nil {
fmt.Fprintf(stderr, "audita process: debug model execution: %s\n", llm.RedactError(err, cfg))
}
if err := runDir.WriteJSONArtifact(diagnostics.ArtifactUtilizationSummary, processreport.BuildUtilization(output, cfg.Backends)); err != nil {
fmt.Fprintf(stderr, "audita process: debug utilization: %s\n", llm.RedactError(err, cfg))
}
}
func runConfig(ctx context.Context, args []string, stdout, stderr io.Writer) int {

View File

@@ -199,3 +199,30 @@ func TestFinalizationCollectsFailuresAndRetainsPublishedTranscript(t *testing.T)
}
}
}
func TestDebugRunDirectoryUsesSuppliedIDAndIsRetained(t *testing.T) {
parent := filepath.Join(t.TempDir(), "debug")
if _, err := os.Stat(parent); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unexpected parent: %v", err)
}
run, err := NewRunDirectory(parent, "run-123", "secret")
if err != nil {
t.Fatal(err)
}
if run.Path() != filepath.Join(parent, "run-123") {
t.Fatalf("run ID changed: %s", run.Path())
}
if _, err := NewRunDirectory(parent, "run-123"); err == nil {
t.Fatal("duplicate run ID accepted")
}
if err := run.WriteJSONArtifact(ArtifactCorrectionLedger, map[string]string{"proposal": "secret"}); err != nil {
t.Fatal(err)
}
contents, err := os.ReadFile(filepath.Join(run.Path(), ArtifactCorrectionLedger))
if err != nil || strings.Contains(string(contents), "secret") {
t.Fatalf("debug artifact: %s %v", contents, err)
}
if _, err := os.Stat(run.Path()); err != nil {
t.Fatalf("debug directory removed: %v", err)
}
}

View File

@@ -1,72 +0,0 @@
package diagnostics
import (
"os"
"path/filepath"
"testing"
)
func TestShouldRetainRunDirectoryMatrix(t *testing.T) {
tests := []struct {
name string
input RetentionDecisionInput
want bool
}{
{name: "always success keeps", input: RetentionDecisionInput{RetentionMode: "always", RunSucceeded: true}, want: true},
{name: "always failure keeps", input: RetentionDecisionInput{RetentionMode: "always", RunSucceeded: false}, want: true},
{name: "never success keeps", input: RetentionDecisionInput{RetentionMode: "never", RunSucceeded: true}, want: true},
{name: "never failure keeps", input: RetentionDecisionInput{RetentionMode: "never", RunSucceeded: false}, want: true},
{name: "auto success no skips removes", input: RetentionDecisionInput{RetentionMode: "auto", RunSucceeded: true, HasSkippedCorrections: false}, want: false},
{name: "auto success skips keeps", input: RetentionDecisionInput{RetentionMode: "auto", RunSucceeded: true, HasSkippedCorrections: true}, want: true},
{name: "auto failure keeps", input: RetentionDecisionInput{RetentionMode: "auto", RunSucceeded: false}, want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := ShouldRetainRunDirectory(tc.input)
if got != tc.want {
t.Fatalf("unexpected retain decision: got=%v want=%v", got, tc.want)
}
})
}
}
func TestApplyRetentionRemovesWhenDecisionSaysRemove(t *testing.T) {
workDir := t.TempDir()
runPath := filepath.Join(workDir, "run-test")
if err := os.Mkdir(runPath, 0o755); err != nil {
t.Fatalf("mkdir run path: %v", err)
}
runDir := &RunDirectory{path: runPath, retention: "auto"}
err := runDir.ApplyRetention(RetentionDecisionInput{RunSucceeded: true, HasSkippedCorrections: false})
if err != nil {
t.Fatalf("ApplyRetention failed: %v", err)
}
if _, err := os.Stat(runPath); !os.IsNotExist(err) {
t.Fatalf("expected run directory removed, stat err=%v", err)
}
}
func TestApplyRetentionReturnsRemovalError(t *testing.T) {
parent := t.TempDir()
runPath := filepath.Join(parent, "run-test")
if err := os.Mkdir(runPath, 0o755); err != nil {
t.Fatalf("mkdir run path: %v", err)
}
// Make parent non-writable so removing child fails.
if err := os.Chmod(parent, 0o500); err != nil {
t.Fatalf("chmod parent: %v", err)
}
t.Cleanup(func() {
_ = os.Chmod(parent, 0o700)
})
runDir := &RunDirectory{path: runPath, retention: "auto"}
err := runDir.ApplyRetention(RetentionDecisionInput{RunSucceeded: true, HasSkippedCorrections: false})
if err == nil {
t.Fatalf("expected removal error, got nil")
}
}

View File

@@ -1,9 +1,11 @@
package diagnostics
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gitea.maximumdirect.net/eric/audita/internal/core/chunking"
@@ -16,42 +18,15 @@ import (
type RunDirectory struct {
redactor redaction.Redactor
path string
retention string
createdAt time.Time
}
type RetentionDecisionInput struct {
RetentionMode string
RunSucceeded bool
HasSkippedCorrections bool
}
func ShouldRetainRunDirectory(input RetentionDecisionInput) bool {
// Failed runs are always retained.
if !input.RunSucceeded {
return true
}
switch input.RetentionMode {
case "always":
return true
case "never":
return true
case "auto":
return input.HasSkippedCorrections
default:
// Be conservative for unknown values.
return true
}
}
// InvocationMetadata captures non-secret invocation details for diagnostics.
type InvocationMetadata struct {
Operation string `json:"operation"`
TranscriptPath string `json:"transcript_path"`
GlossaryPath string `json:"glossary_path"`
OutputPath string `json:"output_path,omitempty"`
ReportJSONPath string `json:"report_json_path,omitempty"`
ConfigPath string `json:"config_path,omitempty"`
ConfigSource string `json:"config_source,omitempty"`
Overlay string `json:"overlay,omitempty"`
@@ -63,9 +38,12 @@ type InvocationMetadata struct {
}
// NewRunDirectory creates a new run directory under the configured work dir
func NewRunDirectory(workDir, retention string, secrets ...string) (*RunDirectory, error) {
if workDir == "" {
workDir = ".audita-runs"
func NewRunDirectory(workDir, runID string, secrets ...string) (*RunDirectory, error) {
if strings.TrimSpace(workDir) == "" {
return nil, fmt.Errorf("debug work directory is required")
}
if strings.TrimSpace(runID) == "" || filepath.Base(runID) != runID || runID == "." || runID == ".." {
return nil, fmt.Errorf("valid run ID is required")
}
// Create work directory if it doesn't exist
@@ -73,8 +51,6 @@ func NewRunDirectory(workDir, retention string, secrets ...string) (*RunDirector
return nil, fmt.Errorf("failed to create work directory %q: %w", workDir, err)
}
// Create a unique run directory identifier.
runID := fmt.Sprintf("run-%d", time.Now().UTC().UnixNano())
runPath := filepath.Join(workDir, runID)
if err := os.Mkdir(runPath, 0o755); err != nil {
@@ -84,7 +60,6 @@ func NewRunDirectory(workDir, retention string, secrets ...string) (*RunDirector
return &RunDirectory{
path: runPath,
redactor: redaction.New(secrets),
retention: retention,
createdAt: time.Now().UTC(),
}, nil
}
@@ -126,9 +101,19 @@ func (r *RunDirectory) WriteEffectiveConfig(effectiveYAML []byte) error {
// WriteSourceTranscript writes the source transcript artifact
func (r *RunDirectory) WriteSourceTranscript(transcript *schema.SourceTranscript, raw []byte) error {
// Write raw source for reference
// Preserve the source snapshot without copying unredacted input bytes.
var source any
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.UseNumber()
if err := decoder.Decode(&source); err != nil {
return fmt.Errorf("decode source snapshot: %w", err)
}
sourceBytes, err := r.redactor.JSON(source)
if err != nil {
return fmt.Errorf("redact source snapshot: %w", err)
}
sourcePath := filepath.Join(r.path, ArtifactSourceTranscript)
if err := os.WriteFile(sourcePath, raw, 0o644); err != nil {
if err := os.WriteFile(sourcePath, append(sourceBytes, '\n'), 0o644); err != nil {
return fmt.Errorf("failed to write source transcript: %w", err)
}
@@ -149,7 +134,7 @@ func (r *RunDirectory) WriteSourceTranscript(transcript *schema.SourceTranscript
// WriteNormalizedTranscript writes the normalized transcript artifact
func (r *RunDirectory) WriteNormalizedTranscript(transcript *schema.Transcript) error {
normalizedPath := filepath.Join(r.path, ArtifactNormalizedTranscript)
bytes, err := schema.TranscriptToJSON(transcript)
bytes, err := r.redactor.JSON(transcript)
if err != nil {
return fmt.Errorf("failed to serialize normalized transcript: %w", err)
}
@@ -218,15 +203,3 @@ func (r *RunDirectory) WriteYAMLArtifact(name string, payload []byte) error {
}
return nil
}
func (r *RunDirectory) ApplyRetention(input RetentionDecisionInput) error {
decision := input
if decision.RetentionMode == "" {
decision.RetentionMode = r.retention
}
if ShouldRetainRunDirectory(decision) {
return nil
}
return os.RemoveAll(r.path)
}

View File

@@ -10,7 +10,7 @@ import (
"gitea.maximumdirect.net/eric/audita/internal/core/diagnostics"
"gitea.maximumdirect.net/eric/audita/internal/core/redaction"
"gitea.maximumdirect.net/eric/audita/internal/core/reporting"
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
)
func TestStringRedactionPreservesSerializationAndDomainData(t *testing.T) {
@@ -51,37 +51,26 @@ func TestStringRedactionPreservesSerializationAndDomainData(t *testing.T) {
}
}
func TestReportWritersRedactStringsAndFailClosed(t *testing.T) {
func TestDebugWritersRedactSourceAndNormalizedSnapshots(t *testing.T) {
dir := t.TempDir()
version := 2
report := reporting.ProcessReport{ErrorMessage: "echo:2"}
report.ReportMetadata.ConfigVersion = &version
run, err := diagnostics.NewRunDirectory(dir, "always", "2")
run, err := diagnostics.NewRunDirectory(dir, "run-test", "2")
if err != nil {
t.Fatal(err)
}
external := filepath.Join(dir, "external.json")
if err := reporting.WriteProcessReport(external, report, "2"); err != nil {
if err := run.WriteSourceTranscript(&schema.SourceTranscript{}, []byte(`{"segments":[{"text":"echo:2","id":2}]}`)); err != nil {
t.Fatal(err)
}
if err := run.WriteReport(report); err != nil {
if err := run.WriteNormalizedTranscript(&schema.Transcript{Segments: []schema.Segment{{ID: 2, Text: "echo:2"}}}); err != nil {
t.Fatal(err)
}
for _, path := range []string{external, filepath.Join(run.Path(), diagnostics.ArtifactReport)} {
for _, path := range []string{filepath.Join(run.Path(), diagnostics.ArtifactSourceTranscript), filepath.Join(run.Path(), diagnostics.ArtifactNormalizedTranscript)} {
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var got reporting.ProcessReport
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatal(err)
if strings.Contains(string(raw), "echo:2") || !strings.Contains(string(raw), "echo:[REDACTED]") || !strings.Contains(string(raw), `"id": 2`) {
t.Fatalf("snapshot: %s", raw)
}
if got.ErrorMessage != "echo:[REDACTED]" || got.ReportMetadata.ConfigVersion == nil || *got.ReportMetadata.ConfigVersion != 2 {
t.Fatalf("report: %+v", got)
}
}
if report.ErrorMessage != "echo:2" {
t.Fatal("mutated input report")
}
if err := run.WriteJSONArtifact("invalid.json", map[string]any{"secret": "echo:2", "invalid": math.NaN()}); err == nil {
t.Fatal("expected serialization failure")

View File

@@ -1,6 +1,7 @@
package processreport
import (
"fmt"
"path/filepath"
"sort"
@@ -9,10 +10,11 @@ import (
)
const (
correctionDispositionApplied = "applied"
correctionDispositionRejected = "rejected"
correctionDispositionSkipped = "skipped"
correctionDispositionFailed = "failed"
correctionDispositionApplied = "applied"
correctionDispositionRejected = "rejected"
correctionDispositionSkipped = "skipped"
correctionDispositionFailed = "failed"
correctionDispositionInterrupted = "interrupted"
)
type CorrectionLedgerInput struct {
@@ -55,6 +57,8 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry
}
entries := make([]CorrectionLedgerEntry, 0)
seen := make(map[string]bool)
identity := func(instance string, index int) string { return instance + "\x00" + fmt.Sprint(index) }
for _, module := range runOutput.ModuleResults {
decisionsByProposal := make(map[int][]runner.ValidatorDecisionRecord)
for _, decision := range module.ValidatorDecisions {
@@ -62,6 +66,7 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry
}
for _, change := range module.AppliedChanges {
seen[identity(module.ModuleInstance, change.ProposalIndex)] = true
entries = append(entries, CorrectionLedgerEntry{
RunID: runID,
ModuleKey: module.ModuleKey,
@@ -78,6 +83,7 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry
})
}
for _, change := range module.SkippedChanges {
seen[identity(module.ModuleInstance, change.ProposalIndex)] = true
entries = append(entries, CorrectionLedgerEntry{
RunID: runID,
ModuleKey: module.ModuleKey,
@@ -95,6 +101,7 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry
})
}
for _, rejection := range module.ValidatorRejected {
seen[identity(module.ModuleInstance, rejection.ProposalIndex)] = true
entries = append(entries, CorrectionLedgerEntry{
RunID: runID,
ModuleKey: module.ModuleKey,
@@ -122,6 +129,34 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry
})
}
}
for _, candidate := range runOutput.Candidates {
if seen[identity(candidate.ModuleInstance, candidate.ProposalIndex)] {
continue
}
var decisions []runner.ValidatorDecisionRecord
policy := ""
for _, module := range runOutput.ModuleResults {
if module.ModuleInstance != candidate.ModuleInstance {
continue
}
policy = string(module.ReplacementPolicy)
for _, decision := range module.ValidatorDecisions {
if decision.ProposalIndex == candidate.ProposalIndex {
decisions = append(decisions, decision)
}
}
break
}
entries = append(entries, CorrectionLedgerEntry{
RunID: runID, ModuleKey: candidate.ModuleKey, ModuleInstance: candidate.ModuleInstance,
ProposalIndex: candidate.ProposalIndex, SegmentID: candidate.TargetSegmentID,
OriginalText: candidate.OriginalText, ProposedCorrectedText: candidate.CorrectedText,
ReplacementPolicy: policy, Disposition: correctionDispositionInterrupted,
DispositionReasonCode: "not_applied", DispositionMessage: "candidate did not reach application",
DeterministicValidatorResults: filterLedgerDecisions(decisions, false),
LLMValidatorResults: filterLedgerDecisions(decisions, true),
})
}
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].ModuleInstance != entries[j].ModuleInstance {

View File

@@ -126,3 +126,25 @@ func TestHasSkippedCorrectionsIncludesApplicationSkipsAndValidatorRejections(t *
t.Fatal("validator rejections should count as skipped corrections")
}
}
func TestCorrectionLedgerIncludesUnappliedCandidateAfterFatalValidation(t *testing.T) {
output := &runner.RunOutput{
Candidates: []proposals.EnrichedCorrectionProposal{{CorrectionProposal: proposals.CorrectionProposal{TargetSegmentID: 7, OriginalText: "old", CorrectedText: "new"}, ProposalMetadata: proposals.ProposalMetadata{ModuleKey: "grammar", ModuleInstance: "grammar", ProposalIndex: 2}}},
ModuleResults: []runner.ModuleResult{{ModuleKey: "grammar", ModuleInstance: "grammar", Status: runner.ModuleStatusFailed,
ValidatorDecisions: []runner.ValidatorDecisionRecord{{ValidatorName: "spoken_form_plausibility", ProposalIndex: 2, Approved: false, ReasonCode: "transport_failed"}}}},
}
ledger := BuildCorrectionLedger(CorrectionLedgerInput{RunOutput: output})
if len(ledger) != 2 {
t.Fatalf("missing candidate: %+v", ledger)
}
for _, entry := range ledger {
if entry.ProposalIndex != 2 {
continue
}
if entry.Disposition != correctionDispositionInterrupted || entry.OriginalText != "old" || len(entry.LLMValidatorResults) != 1 {
t.Fatalf("candidate evidence lost: %+v", entry)
}
return
}
t.Fatal("candidate missing")
}

View File

@@ -94,20 +94,22 @@ type RunInput struct {
ProposalDiagnosticsDir string
ValidationLLMClient contracts.StructuredLLMClient
ValidationDiagnosticsDir string
CaptureCandidates bool
timings *validatorTimingCollector
}
// RunOutput is the deterministic runner output.
type RunOutput struct {
FinalTranscript *schema.Transcript `json:"-"`
ModuleResults []ModuleResult `json:"module_results"`
Sections []contracts.SectionMetadata `json:"sections"`
WorkUnits []WorkUnitOutcome `json:"work_units"`
Observations llm.ObservationSnapshot `json:"-"`
ObservationsAvailable bool `json:"-"`
LLMSummary llm.ExecutionSummary `json:"-"`
RunWallTimeMS int64 `json:"-"`
ValidatorTimings []ValidatorTiming `json:"-"`
FinalTranscript *schema.Transcript `json:"-"`
ModuleResults []ModuleResult `json:"module_results"`
Sections []contracts.SectionMetadata `json:"sections"`
WorkUnits []WorkUnitOutcome `json:"work_units"`
Candidates []proposals.EnrichedCorrectionProposal `json:"-"`
Observations llm.ObservationSnapshot `json:"-"`
ObservationsAvailable bool `json:"-"`
LLMSummary llm.ExecutionSummary `json:"-"`
RunWallTimeMS int64 `json:"-"`
ValidatorTimings []ValidatorTiming `json:"-"`
}
func New(factory ModuleFactory) *Runner {
@@ -136,6 +138,7 @@ func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
}
inventory := make([]contracts.SectionMetadata, len(sections))
workUnits := make([]WorkUnitOutcome, 0, len(input.ModuleSpecs)*len(sections))
var candidates []proposals.EnrichedCorrectionProposal
for i, section := range sections {
inventory[i] = contracts.SectionMetadataFromSection(section)
}
@@ -151,7 +154,7 @@ func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
snapshot := func() RunOutput {
return RunOutput{
FinalTranscript: transcriptFromSections(sections), ModuleResults: results,
Sections: inventory, WorkUnits: workUnits,
Sections: inventory, WorkUnits: workUnits, Candidates: candidates,
}
}
@@ -185,6 +188,9 @@ func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
ValidationDiagnosticsDir: input.ValidationDiagnosticsDir,
Timings: input.timings,
})
if input.CaptureCandidates {
candidates = append(candidates, pipelineResult.Candidates...)
}
if pipelineErr != nil {
failed := ModuleResult{
ModuleKey: spec.ModuleKey,
@@ -321,6 +327,7 @@ type sectionValidationResult struct {
type modulePipelineResult struct {
ProposalCount int
Candidates []proposals.EnrichedCorrectionProposal
Approved []proposals.EnrichedCorrectionProposal
Operations []outcomes.Operation
WorkUnits []WorkUnitOutcome
@@ -473,6 +480,7 @@ func runModulePipeline(ctx context.Context, input collectSectionProposalsInput)
}
nextProposalIndex += len(sectionEnriched)
out.ProposalCount += len(sectionEnriched)
out.Candidates = append(out.Candidates, sectionEnriched...)
for operationIndex := range out.WorkUnits[nextSectionToProcess].Operations {
operation := &out.WorkUnits[nextSectionToProcess].Operations[operationIndex]
if operation.Reference.Kind == outcomes.OperationProposal && operation.Succeeded {

View File

@@ -128,6 +128,31 @@ func TestFatalValidatorNeverReturnsUncheckedCandidates(t *testing.T) {
}
}
func TestCandidateCaptureIsExplicitAndIncludesFilteredProposals(t *testing.T) {
module := fakeModule{key: "grammar", policy: proposals.ReplacementPolicyRequireUnique,
validators: []contracts.Validator{validators.ProposalShapeValidator{}},
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
return []proposals.CorrectionProposal{{TargetSegmentID: 0, OriginalText: "bad", CorrectedText: "good", Confidence: 1}}, nil
}}
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{"grammar": module}})
input := RunInput{Transcript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "bad"}}}, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "grammar", InstanceName: "grammar"}}}
without, err := r.Run(context.Background(), input)
if err != nil {
t.Fatal(err)
}
if len(without.Candidates) != 0 {
t.Fatalf("debug-off candidate capture: %+v", without.Candidates)
}
input.CaptureCandidates = true
with, err := r.Run(context.Background(), input)
if err != nil {
t.Fatal(err)
}
if len(with.Candidates) != 1 || with.Candidates[0].ProposalIndex != 0 || len(with.ModuleResults[0].ValidatorRejected) != 1 {
t.Fatalf("filtered candidate missing from debug evidence: %+v", with)
}
}
func (m outcomeModule) Propose(_ context.Context, req contracts.ProposalRequest) (contracts.ProposalResult, error) {
return m.proposeResult(req)
}