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

@@ -709,6 +709,17 @@ reported at finalization without erasing a completed transcript.
## Stage 11 — Make Debug Persistence Explicit and Independent ## Stage 11 — Make Debug Persistence Explicit and Independent
**Completed 2026-09-24.** Debug directory creation and all current debug writer
paths are gated by `diagnostics.debug`; failures are reported safely to stderr
without aborting processing. The retained directory uses the supplied run ID,
old retention modes are gone, raw and normalized snapshots are redacted, and the
candidate ledger includes proposals not applied after filtering or interruption.
Tests cover explicit candidate capture, retained debug evidence, secret redaction,
and writer behavior. `go test ./internal/core/redaction
./internal/core/diagnostics ./internal/framework/runner
./internal/framework/processreport` and `git diff --check` passed. CLI package
compilation is deferred to Stage 12's replacement of the old report lifecycle.
**Work:** Refactor `core/diagnostics` run-directory creation and all CLI/module/ **Work:** Refactor `core/diagnostics` run-directory creation and all CLI/module/
proposal/validator writer wiring for H. Only debug creates the unique retained proposal/validator writer wiring for H. Only debug creates the unique retained
run directory. Gate snapshots, interaction files, ledger, and full execution data run directory. Gate snapshots, interaction files, ledger, and full execution data

View File

@@ -28,6 +28,7 @@ type processInvocation struct {
GlossaryPath string GlossaryPath string
OutputPath string OutputPath string
ReportDir string ReportDir string
RunID string
Config config.Config Config config.Config
ConfigResult *ck.Result[config.Config] ConfigResult *ck.Result[config.Config]
LLMRuntime *llm.Runtime LLMRuntime *llm.Runtime
@@ -37,46 +38,65 @@ type processInvocation struct {
ConfigVersion *int ConfigVersion *int
} }
var processRunner = func(ctx context.Context, inv processInvocation, stdout io.Writer) (*normalization.NormalizationSummary, *chunking.Summary, *runner.RunOutput, *diagnostics.RunDirectory, error) { var processRunner = func(ctx context.Context, inv processInvocation, stdout, stderr io.Writer) (*normalization.NormalizationSummary, *chunking.Summary, *runner.RunOutput, *diagnostics.RunDirectory, error) {
runDir, err := diagnostics.NewRunDirectory(inv.Config.Diagnostics.WorkDir, "always", llm.ConfiguredSecrets(&inv.Config)...) var runDir *diagnostics.RunDirectory
if err != nil { if inv.Config.Diagnostics.Debug {
return nil, nil, nil, nil, fmt.Errorf("run_dir_creation: %w", err) 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) { 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) return nil, nil, runOutput, runDir, fmt.Errorf("%s: %w", phase, err)
} }
if err := runDir.WriteInvocationMetadata(diagnostics.InvocationMetadata{ debugWrite("invocation", func(d *diagnostics.RunDirectory) error {
Operation: "process", return d.WriteInvocationMetadata(diagnostics.InvocationMetadata{
TranscriptPath: inv.TranscriptPath, Operation: "process",
GlossaryPath: inv.GlossaryPath, TranscriptPath: inv.TranscriptPath,
OutputPath: inv.OutputPath, GlossaryPath: inv.GlossaryPath,
ConfigPath: inv.ConfigPath, OutputPath: inv.OutputPath,
ConfigSource: inv.ConfigSource, ConfigPath: inv.ConfigPath,
Overlay: inv.Overlay, ConfigSource: inv.ConfigSource,
ConfigVersion: inv.ConfigVersion, Overlay: inv.Overlay,
TranscriptDescription: inv.Config.Context.Description, ConfigVersion: inv.ConfigVersion,
Modules: append([]string(nil), inv.Config.Pipeline.Modules...), TranscriptDescription: inv.Config.Context.Description,
}); err != nil { Modules: append([]string(nil), inv.Config.Pipeline.Modules...),
_ = runDir.WriteErrorLog(fmt.Sprintf("invocation_metadata: %v", err)) })
} })
if inv.ConfigResult != nil { if runDir != nil && inv.ConfigResult != nil {
var effective bytes.Buffer var effective bytes.Buffer
if err := inv.ConfigResult.WriteEffective(&effective); err == nil { if err := inv.ConfigResult.WriteEffective(&effective); err == nil {
err = runDir.WriteEffectiveConfig(effective.Bytes()) debugWrite("effective settings", func(d *diagnostics.RunDirectory) error { return d.WriteEffectiveConfig(effective.Bytes()) })
}
if err != nil {
_ = runDir.WriteErrorLog(fmt.Sprintf("effective_config: %v", err))
} }
if origins, err := llm.ExplanationYAML(inv.ConfigResult); err == nil { 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 { 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) return fail("glossary_schema", err, nil)
} }
if err := runDir.WriteSourceTranscript(sourceTranscript, transcriptBytes); err != nil { debugWrite("source transcript", func(d *diagnostics.RunDirectory) error {
_ = runDir.WriteErrorLog(fmt.Sprintf("source_artifact: %v", err)) return d.WriteSourceTranscript(sourceTranscript, transcriptBytes)
} })
canonical := sourceToCanonicalTranscript(sourceTranscript) canonical := sourceToCanonicalTranscript(sourceTranscript)
normalizer := normalization.NewNormalizer(normalization.NormalizationConfig{ 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) normalizedTranscript, normSummary := normalizer.Normalize(canonical)
if err := runDir.WriteNormalizedTranscript(normalizedTranscript); err != nil { debugWrite("normalized transcript", func(d *diagnostics.RunDirectory) error { return d.WriteNormalizedTranscript(normalizedTranscript) })
_ = runDir.WriteErrorLog(fmt.Sprintf("normalized_artifact: %v", err)) debugWrite("normalization summary", func(d *diagnostics.RunDirectory) error { return d.WriteNormalizationSummary(normSummary) })
}
if err := runDir.WriteNormalizationSummary(normSummary); err != nil {
_ = runDir.WriteErrorLog(fmt.Sprintf("normalization_summary: %v", err))
}
// Compute chunks after normalization // Compute chunks after normalization
chunker := chunking.NewChunker(chunking.ChunkingConfig{ 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) chunkSummary := chunking.ComputeSummary(sections, chunkConfig)
chunkDetailedSummary := chunking.ComputeDetailedSummary(sections, chunkConfig) chunkDetailedSummary := chunking.ComputeDetailedSummary(sections, chunkConfig)
if err := runDir.WriteChunkingSummary(&chunkDetailedSummary); err != nil { debugWrite("chunking summary", func(d *diagnostics.RunDirectory) error { return d.WriteChunkingSummary(&chunkDetailedSummary) })
_ = runDir.WriteErrorLog(fmt.Sprintf("chunking_summary: %v", err))
}
workingTranscript := normalizedTranscript workingTranscript := normalizedTranscript
var runOutput *runner.RunOutput var runOutput *runner.RunOutput
observations := llm.NewObservationCollector() 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 { if inv.LLMRuntime == nil {
return fail("runner_setup", fmt.Errorf("LLM runtime is required"), 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, ModuleSpecs: moduleSpecs,
ProposalLLMClient: proposalLLMClient, ProposalLLMClient: proposalLLMClient,
ProposalDiagnosticsDir: runDir.Path(), ProposalDiagnosticsDir: debugPath,
ValidationLLMClient: validationLLMClient, ValidationLLMClient: validationLLMClient,
ValidationDiagnosticsDir: runDir.Path(), ValidationDiagnosticsDir: debugPath,
CaptureCandidates: runDir != nil,
}) })
runnerResult.Observations = observations.Snapshot() runnerResult.Observations = observations.Snapshot()
runnerResult.ObservationsAvailable = true runnerResult.ObservationsAvailable = true
@@ -311,26 +326,24 @@ func runProcess(ctx context.Context, args []string, stdout, stderr io.Writer) in
ConfigVersion: &version, 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() completedAt := time.Now().UTC()
if runErr != nil { if runErr != nil {
if runDir != nil && runOutput != 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(), RunDirectoryPath: runDir.Path(),
RunOutput: runOutput, RunOutput: runOutput,
})) })); debugErr != nil {
fmt.Fprintf(stderr, "audita process: debug correction ledger: %s\n", llm.RedactError(debugErr, inv.Config))
}
} }
errorPhase, errorMessage := extractErrorPhase(runErr) errorPhase, errorMessage := extractErrorPhase(runErr)
errorMessage = llm.RedactError(fmt.Errorf("%s", errorMessage), inv.Config) errorMessage = llm.RedactError(fmt.Errorf("%s", errorMessage), inv.Config)
report := processreport.Build(processReportInput("failed", inv, runDir, startedAt, completedAt, errorMessage, errorPhase, nil, nil, runOutput)) 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)) fmt.Fprintf(stderr, "audita process: %s\n", llm.RedactError(runErr, inv.Config))
if runDir != nil { if runDir != nil {
fmt.Fprintf(stderr, "audita process: diagnostics: %s\n", runDir.Path()) 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 { 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(), RunDirectoryPath: runDir.Path(),
RunOutput: runOutput, 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)) report := processreport.Build(processReportInput("success", inv, runDir, startedAt, completedAt, "", "", normSummary, chunkSummary, runOutput))
if runDir != nil {
_ = runDir.WriteReport(report)
}
return 0 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 { if !output.ObservationsAvailable {
return return
} }
_ = runDir.WriteJSONArtifact(diagnostics.ArtifactLLMExecution, processreport.BuildLLMExecution(output.Observations)) if err := runDir.WriteJSONArtifact(diagnostics.ArtifactLLMExecution, processreport.BuildLLMExecution(output.Observations)); err != nil {
_ = runDir.WriteJSONArtifact(diagnostics.ArtifactUtilizationSummary, processreport.BuildUtilization(output, cfg.Backends)) 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 { 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 package diagnostics
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"gitea.maximumdirect.net/eric/audita/internal/core/chunking" "gitea.maximumdirect.net/eric/audita/internal/core/chunking"
@@ -16,42 +18,15 @@ import (
type RunDirectory struct { type RunDirectory struct {
redactor redaction.Redactor redactor redaction.Redactor
path string path string
retention string
createdAt time.Time 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. // InvocationMetadata captures non-secret invocation details for diagnostics.
type InvocationMetadata struct { type InvocationMetadata struct {
Operation string `json:"operation"` Operation string `json:"operation"`
TranscriptPath string `json:"transcript_path"` TranscriptPath string `json:"transcript_path"`
GlossaryPath string `json:"glossary_path"` GlossaryPath string `json:"glossary_path"`
OutputPath string `json:"output_path,omitempty"` OutputPath string `json:"output_path,omitempty"`
ReportJSONPath string `json:"report_json_path,omitempty"`
ConfigPath string `json:"config_path,omitempty"` ConfigPath string `json:"config_path,omitempty"`
ConfigSource string `json:"config_source,omitempty"` ConfigSource string `json:"config_source,omitempty"`
Overlay string `json:"overlay,omitempty"` Overlay string `json:"overlay,omitempty"`
@@ -63,9 +38,12 @@ type InvocationMetadata struct {
} }
// NewRunDirectory creates a new run directory under the configured work dir // NewRunDirectory creates a new run directory under the configured work dir
func NewRunDirectory(workDir, retention string, secrets ...string) (*RunDirectory, error) { func NewRunDirectory(workDir, runID string, secrets ...string) (*RunDirectory, error) {
if workDir == "" { if strings.TrimSpace(workDir) == "" {
workDir = ".audita-runs" 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 // 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) 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) runPath := filepath.Join(workDir, runID)
if err := os.Mkdir(runPath, 0o755); err != nil { if err := os.Mkdir(runPath, 0o755); err != nil {
@@ -84,7 +60,6 @@ func NewRunDirectory(workDir, retention string, secrets ...string) (*RunDirector
return &RunDirectory{ return &RunDirectory{
path: runPath, path: runPath,
redactor: redaction.New(secrets), redactor: redaction.New(secrets),
retention: retention,
createdAt: time.Now().UTC(), createdAt: time.Now().UTC(),
}, nil }, nil
} }
@@ -126,9 +101,19 @@ func (r *RunDirectory) WriteEffectiveConfig(effectiveYAML []byte) error {
// WriteSourceTranscript writes the source transcript artifact // WriteSourceTranscript writes the source transcript artifact
func (r *RunDirectory) WriteSourceTranscript(transcript *schema.SourceTranscript, raw []byte) error { 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) 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) 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 // WriteNormalizedTranscript writes the normalized transcript artifact
func (r *RunDirectory) WriteNormalizedTranscript(transcript *schema.Transcript) error { func (r *RunDirectory) WriteNormalizedTranscript(transcript *schema.Transcript) error {
normalizedPath := filepath.Join(r.path, ArtifactNormalizedTranscript) normalizedPath := filepath.Join(r.path, ArtifactNormalizedTranscript)
bytes, err := schema.TranscriptToJSON(transcript) bytes, err := r.redactor.JSON(transcript)
if err != nil { if err != nil {
return fmt.Errorf("failed to serialize normalized transcript: %w", err) 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 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/diagnostics"
"gitea.maximumdirect.net/eric/audita/internal/core/redaction" "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) { 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() dir := t.TempDir()
version := 2 run, err := diagnostics.NewRunDirectory(dir, "run-test", "2")
report := reporting.ProcessReport{ErrorMessage: "echo:2"}
report.ReportMetadata.ConfigVersion = &version
run, err := diagnostics.NewRunDirectory(dir, "always", "2")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
external := filepath.Join(dir, "external.json") if err := run.WriteSourceTranscript(&schema.SourceTranscript{}, []byte(`{"segments":[{"text":"echo:2","id":2}]}`)); err != nil {
if err := reporting.WriteProcessReport(external, report, "2"); err != nil {
t.Fatal(err) 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) 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) raw, err := os.ReadFile(path)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var got reporting.ProcessReport if strings.Contains(string(raw), "echo:2") || !strings.Contains(string(raw), "echo:[REDACTED]") || !strings.Contains(string(raw), `"id": 2`) {
if err := json.Unmarshal(raw, &got); err != nil { t.Fatalf("snapshot: %s", raw)
t.Fatal(err)
} }
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 { if err := run.WriteJSONArtifact("invalid.json", map[string]any{"secret": "echo:2", "invalid": math.NaN()}); err == nil {
t.Fatal("expected serialization failure") t.Fatal("expected serialization failure")

View File

@@ -1,6 +1,7 @@
package processreport package processreport
import ( import (
"fmt"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -9,10 +10,11 @@ import (
) )
const ( const (
correctionDispositionApplied = "applied" correctionDispositionApplied = "applied"
correctionDispositionRejected = "rejected" correctionDispositionRejected = "rejected"
correctionDispositionSkipped = "skipped" correctionDispositionSkipped = "skipped"
correctionDispositionFailed = "failed" correctionDispositionFailed = "failed"
correctionDispositionInterrupted = "interrupted"
) )
type CorrectionLedgerInput struct { type CorrectionLedgerInput struct {
@@ -55,6 +57,8 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry
} }
entries := make([]CorrectionLedgerEntry, 0) 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 { for _, module := range runOutput.ModuleResults {
decisionsByProposal := make(map[int][]runner.ValidatorDecisionRecord) decisionsByProposal := make(map[int][]runner.ValidatorDecisionRecord)
for _, decision := range module.ValidatorDecisions { for _, decision := range module.ValidatorDecisions {
@@ -62,6 +66,7 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry
} }
for _, change := range module.AppliedChanges { for _, change := range module.AppliedChanges {
seen[identity(module.ModuleInstance, change.ProposalIndex)] = true
entries = append(entries, CorrectionLedgerEntry{ entries = append(entries, CorrectionLedgerEntry{
RunID: runID, RunID: runID,
ModuleKey: module.ModuleKey, ModuleKey: module.ModuleKey,
@@ -78,6 +83,7 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry
}) })
} }
for _, change := range module.SkippedChanges { for _, change := range module.SkippedChanges {
seen[identity(module.ModuleInstance, change.ProposalIndex)] = true
entries = append(entries, CorrectionLedgerEntry{ entries = append(entries, CorrectionLedgerEntry{
RunID: runID, RunID: runID,
ModuleKey: module.ModuleKey, ModuleKey: module.ModuleKey,
@@ -95,6 +101,7 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry
}) })
} }
for _, rejection := range module.ValidatorRejected { for _, rejection := range module.ValidatorRejected {
seen[identity(module.ModuleInstance, rejection.ProposalIndex)] = true
entries = append(entries, CorrectionLedgerEntry{ entries = append(entries, CorrectionLedgerEntry{
RunID: runID, RunID: runID,
ModuleKey: module.ModuleKey, 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 { sort.SliceStable(entries, func(i, j int) bool {
if entries[i].ModuleInstance != entries[j].ModuleInstance { 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") 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 ProposalDiagnosticsDir string
ValidationLLMClient contracts.StructuredLLMClient ValidationLLMClient contracts.StructuredLLMClient
ValidationDiagnosticsDir string ValidationDiagnosticsDir string
CaptureCandidates bool
timings *validatorTimingCollector 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"`
Sections []contracts.SectionMetadata `json:"sections"` Sections []contracts.SectionMetadata `json:"sections"`
WorkUnits []WorkUnitOutcome `json:"work_units"` WorkUnits []WorkUnitOutcome `json:"work_units"`
Observations llm.ObservationSnapshot `json:"-"` Candidates []proposals.EnrichedCorrectionProposal `json:"-"`
ObservationsAvailable bool `json:"-"` Observations llm.ObservationSnapshot `json:"-"`
LLMSummary llm.ExecutionSummary `json:"-"` ObservationsAvailable bool `json:"-"`
RunWallTimeMS int64 `json:"-"` LLMSummary llm.ExecutionSummary `json:"-"`
ValidatorTimings []ValidatorTiming `json:"-"` RunWallTimeMS int64 `json:"-"`
ValidatorTimings []ValidatorTiming `json:"-"`
} }
func New(factory ModuleFactory) *Runner { 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)) inventory := make([]contracts.SectionMetadata, len(sections))
workUnits := make([]WorkUnitOutcome, 0, len(input.ModuleSpecs)*len(sections)) workUnits := make([]WorkUnitOutcome, 0, len(input.ModuleSpecs)*len(sections))
var candidates []proposals.EnrichedCorrectionProposal
for i, section := range sections { for i, section := range sections {
inventory[i] = contracts.SectionMetadataFromSection(section) inventory[i] = contracts.SectionMetadataFromSection(section)
} }
@@ -151,7 +154,7 @@ func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
snapshot := func() RunOutput { snapshot := func() RunOutput {
return RunOutput{ return RunOutput{
FinalTranscript: transcriptFromSections(sections), ModuleResults: results, 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, ValidationDiagnosticsDir: input.ValidationDiagnosticsDir,
Timings: input.timings, Timings: input.timings,
}) })
if input.CaptureCandidates {
candidates = append(candidates, pipelineResult.Candidates...)
}
if pipelineErr != nil { if pipelineErr != nil {
failed := ModuleResult{ failed := ModuleResult{
ModuleKey: spec.ModuleKey, ModuleKey: spec.ModuleKey,
@@ -321,6 +327,7 @@ type sectionValidationResult struct {
type modulePipelineResult struct { type modulePipelineResult struct {
ProposalCount int ProposalCount int
Candidates []proposals.EnrichedCorrectionProposal
Approved []proposals.EnrichedCorrectionProposal Approved []proposals.EnrichedCorrectionProposal
Operations []outcomes.Operation Operations []outcomes.Operation
WorkUnits []WorkUnitOutcome WorkUnits []WorkUnitOutcome
@@ -473,6 +480,7 @@ func runModulePipeline(ctx context.Context, input collectSectionProposalsInput)
} }
nextProposalIndex += len(sectionEnriched) nextProposalIndex += len(sectionEnriched)
out.ProposalCount += len(sectionEnriched) out.ProposalCount += len(sectionEnriched)
out.Candidates = append(out.Candidates, sectionEnriched...)
for operationIndex := range out.WorkUnits[nextSectionToProcess].Operations { for operationIndex := range out.WorkUnits[nextSectionToProcess].Operations {
operation := &out.WorkUnits[nextSectionToProcess].Operations[operationIndex] operation := &out.WorkUnits[nextSectionToProcess].Operations[operationIndex]
if operation.Reference.Kind == outcomes.OperationProposal && operation.Succeeded { 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) { func (m outcomeModule) Propose(_ context.Context, req contracts.ProposalRequest) (contracts.ProposalResult, error) {
return m.proposeResult(req) return m.proposeResult(req)
} }