Preserve empty deletion spans in debug ledger

This commit is contained in:
2026-09-25 02:52:09 +00:00
parent 93f62c9095
commit 2a58db04c2
5 changed files with 78 additions and 12 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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**

View File

@@ -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),

View File

@@ -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)
}
}
}