From 2a58db04c25fd99f7a77bf59ef5c6ddf4207e31f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 25 Sep 2026 02:52:09 +0000 Subject: [PATCH] Preserve empty deletion spans in debug ledger --- docs/integrations/output-files.md | 8 +++ docs/internal/diagnostics-reporting.md | 7 ++- docs/roadmap/implementation.md | 8 ++- .../processreport/correction_ledger.go | 14 ++--- .../processreport/correction_ledger_test.go | 53 ++++++++++++++++++- 5 files changed, 78 insertions(+), 12 deletions(-) diff --git a/docs/integrations/output-files.md b/docs/integrations/output-files.md index 74d334b..bb92db8 100644 --- a/docs/integrations/output-files.md +++ b/docs/integrations/output-files.md @@ -88,3 +88,11 @@ groups backend capacity and timing observations while preserving known and complete usage semantics. Interaction request/response artifacts, selected profiles, configuration provenance, and input snapshots are also debug-only. These files are diagnostic evidence, not an alternate ordinary report schema. +Each candidate-backed ledger entry includes `proposed_corrected_text`, the +literal replacement span, even when it is `""` for a deletion. Applied entries +also include `applied_corrected_text`, likewise `""` for an applied deletion. +Rejected, skipped, and interrupted entries omit the applied field; a module +failure without a candidate omits both. These fields are replacement spans, +not full resulting-segment text. Deterministic decision records now identify +the blank-result guard as `non_empty_resulting_segment` (formerly +`non_empty_corrected_text`); its `empty_resulting_segment` reason is unchanged. diff --git a/docs/internal/diagnostics-reporting.md b/docs/internal/diagnostics-reporting.md index 36b20e3..8f11049 100644 --- a/docs/internal/diagnostics-reporting.md +++ b/docs/internal/diagnostics-reporting.md @@ -32,8 +32,11 @@ under `diagnostics.work_dir`. CLI passes empty diagnostics paths and nil writers when debug is off. With debug on, source/normalized snapshots, invocation, effective settings, origins, profiles, initial chunk summary, correction ledger, indexed execution/utilization, and model interactions are written. Runner -candidate capture includes proposals later rejected or interrupted. Debug -write failures reach the CLI through an explicit concurrency-safe diagnostic +candidate capture includes proposals later rejected or interrupted. Ledger +mapping uses optional string pointers so an empty proposed replacement is +serialized for every candidate and an empty applied replacement appears only +when that candidate applied; see the [public artifact contract](../integrations/output-files.md#debug-only-files). +Debug write failures reach the CLI through an explicit concurrency-safe diagnostic callback and produce redacted stderr messages without aborting processing. Completed validator decisions returned with a fatal batch error remain available to the debug ledger; they never authorize applying an incompletely validated edit. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 83b5cab..16efa50 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -2,7 +2,7 @@ **Last updated:** 2026-09-25 -**Status:** In progress; Stages 1–2 complete. +**Status:** In progress; Stages 1–3 complete. **Source baseline:** `a83a518` (feature roadmap); application baseline `0b6cd98`. **Audience:** gpt-5.6-terra coding agent. @@ -271,6 +271,12 @@ existing configured-secret redaction and debug-off persistence coverage. **End state:** Debug evidence distinguishes a proposed/applied deletion from an absent proposal or an edit that was never applied. +**Completed 2026-09-25.** Candidate-backed ledger entries now preserve an +explicit empty proposed replacement, and applied entries preserve an explicit +empty applied replacement. Serialized disposition coverage and the specified +focused package tests passed; the artifact contract documents the fields and +validator identity change. + ## Stage 4 — Verify the Integrated Contract and Finish Documentation **Work** diff --git a/internal/framework/processreport/correction_ledger.go b/internal/framework/processreport/correction_ledger.go index 31a035f..fff9be1 100644 --- a/internal/framework/processreport/correction_ledger.go +++ b/internal/framework/processreport/correction_ledger.go @@ -29,8 +29,8 @@ type CorrectionLedgerEntry struct { ProposalIndex int `json:"proposal_index"` SegmentID int `json:"segment_id,omitempty"` OriginalText string `json:"original_text,omitempty"` - ProposedCorrectedText string `json:"proposed_corrected_text,omitempty"` - AppliedCorrectedText string `json:"applied_corrected_text,omitempty"` + ProposedCorrectedText *string `json:"proposed_corrected_text,omitempty"` + AppliedCorrectedText *string `json:"applied_corrected_text,omitempty"` ReplacementPolicy string `json:"replacement_policy,omitempty"` Disposition string `json:"disposition"` DispositionReasonCode string `json:"disposition_reason_code,omitempty"` @@ -74,8 +74,8 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry ProposalIndex: change.ProposalIndex, SegmentID: change.TargetSegmentID, OriginalText: change.OriginalText, - ProposedCorrectedText: change.CorrectedText, - AppliedCorrectedText: change.CorrectedText, + ProposedCorrectedText: &change.CorrectedText, + AppliedCorrectedText: &change.CorrectedText, ReplacementPolicy: string(module.ReplacementPolicy), Disposition: correctionDispositionApplied, DeterministicValidatorResults: filterLedgerDecisions(decisionsByProposal[change.ProposalIndex], false), @@ -91,7 +91,7 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry ProposalIndex: change.ProposalIndex, SegmentID: change.TargetSegmentID, OriginalText: change.OriginalText, - ProposedCorrectedText: change.CorrectedText, + ProposedCorrectedText: &change.CorrectedText, ReplacementPolicy: string(module.ReplacementPolicy), Disposition: correctionDispositionSkipped, DispositionReasonCode: string(change.SkipReason), @@ -109,7 +109,7 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry ProposalIndex: rejection.ProposalIndex, SegmentID: rejection.TargetSegmentID, OriginalText: rejection.OriginalText, - ProposedCorrectedText: rejection.CorrectedText, + ProposedCorrectedText: &rejection.CorrectedText, ReplacementPolicy: string(module.ReplacementPolicy), Disposition: correctionDispositionRejected, DispositionReasonCode: rejection.ReasonCode, @@ -150,7 +150,7 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry entries = append(entries, CorrectionLedgerEntry{ RunID: runID, ModuleKey: candidate.ModuleKey, ModuleInstance: candidate.ModuleInstance, ProposalIndex: candidate.ProposalIndex, SegmentID: candidate.TargetSegmentID, - OriginalText: candidate.OriginalText, ProposedCorrectedText: candidate.CorrectedText, + OriginalText: candidate.OriginalText, ProposedCorrectedText: &candidate.CorrectedText, ReplacementPolicy: policy, Disposition: correctionDispositionInterrupted, DispositionReasonCode: "not_applied", DispositionMessage: "candidate did not reach application", DeterministicValidatorResults: filterLedgerDecisions(decisions, false), diff --git a/internal/framework/processreport/correction_ledger_test.go b/internal/framework/processreport/correction_ledger_test.go index f421f86..ca3ae9d 100644 --- a/internal/framework/processreport/correction_ledger_test.go +++ b/internal/framework/processreport/correction_ledger_test.go @@ -1,6 +1,7 @@ package processreport import ( + "encoding/json" "testing" "gitea.maximumdirect.net/eric/audita/internal/framework/proposals" @@ -40,7 +41,7 @@ func TestBuildCorrectionLedgerClassifiesValidatorDecisionsFromCanonicalMetadata( t.Fatalf("expected one ledger entry, got %d", len(ledger)) } entry := ledger[0] - if entry.RunID != "audita-run-id" || entry.Disposition != "applied" || entry.AppliedCorrectedText != "Jesters" { + if entry.RunID != "audita-run-id" || entry.Disposition != "applied" || entry.AppliedCorrectedText == nil || *entry.AppliedCorrectedText != "Jesters" { t.Fatalf("unexpected applied ledger entry: %+v", entry) } if len(entry.DeterministicValidatorResults) != 1 || entry.DeterministicValidatorResults[0].ValidatorKey != "proposal_shape" { @@ -103,7 +104,7 @@ func TestBuildCorrectionLedgerPreservesDispositionPolicy(t *testing.T) { t.Fatalf("unexpected skipped ledger entry: %+v", byDisposition["skipped"]) } if byDisposition["rejected"].DispositionReasonCode != "protected_term" || - byDisposition["rejected"].ProposedCorrectedText != "after" { + byDisposition["rejected"].ProposedCorrectedText == nil || *byDisposition["rejected"].ProposedCorrectedText != "after" { t.Fatalf("unexpected rejected ledger entry: %+v", byDisposition["rejected"]) } if byDisposition["failed"].DispositionReasonCode != "module_failed" || @@ -133,3 +134,51 @@ func TestCorrectionLedgerIncludesUnappliedCandidateAfterFatalValidation(t *testi } t.Fatal("candidate missing") } + +func TestCorrectionLedgerSerializesEmptyReplacementByDisposition(t *testing.T) { + output := &runner.RunOutput{ + ModuleResults: []runner.ModuleResult{ + { + ModuleKey: "spoken_word", ModuleInstance: "spoken_word", ReplacementPolicy: proposals.ReplacementPolicyRequireUnique, + AppliedChanges: []proposals.AppliedChange{{ProposalIndex: 0, TargetSegmentID: 1, OriginalText: "uh ", CorrectedText: ""}}, + ValidatorRejected: []runner.ValidatorRejectedChange{{ProposalIndex: 1, TargetSegmentID: 2, OriginalText: "uh ", CorrectedText: "", ReasonCode: "llm_rejected"}}, + SkippedChanges: []proposals.SkippedChange{{ProposalIndex: 2, TargetSegmentID: 3, OriginalText: "uh ", CorrectedText: "", SkipReason: proposals.SkipReasonEmptyResultingText}}, + }, + {ModuleKey: "grammar", ModuleInstance: "grammar", Status: runner.ModuleStatusFailed, ErrorMessage: "failed"}, + }, + Candidates: []proposals.EnrichedCorrectionProposal{{CorrectionProposal: proposals.CorrectionProposal{TargetSegmentID: 4, OriginalText: "uh ", CorrectedText: ""}, ProposalMetadata: proposals.ProposalMetadata{ModuleKey: "spoken_word", ModuleInstance: "spoken_word", ProposalIndex: 3}}}, + } + ledger := BuildCorrectionLedger(CorrectionLedgerInput{RunOutput: output}) + byDisposition := make(map[string]map[string]json.RawMessage) + for _, entry := range ledger { + raw, err := json.Marshal(entry) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + byDisposition[entry.Disposition] = fields + } + for _, tc := range []struct { + disposition string + proposed, applied bool + }{ + {correctionDispositionApplied, true, true}, + {correctionDispositionRejected, true, false}, + {correctionDispositionSkipped, true, false}, + {correctionDispositionInterrupted, true, false}, + {correctionDispositionFailed, false, false}, + } { + fields, ok := byDisposition[tc.disposition] + if !ok { + t.Fatalf("missing %s entry: %+v", tc.disposition, byDisposition) + } + proposed, proposedPresent := fields["proposed_corrected_text"] + applied, appliedPresent := fields["applied_corrected_text"] + if proposedPresent != tc.proposed || appliedPresent != tc.applied || (proposedPresent && string(proposed) != `""`) || (appliedPresent && string(applied) != `""`) { + t.Fatalf("incorrect serialized replacement fields for %s: %+v", tc.disposition, fields) + } + } +}