Track Audita v2 selections in polish resume evidence

This commit is contained in:
2026-09-25 15:55:38 +00:00
parent 569f467393
commit 2d167d0d67
7 changed files with 128 additions and 23 deletions

View File

@@ -35,19 +35,18 @@ are recorded as diagnostics, not artifact outputs.
## Resume Semantics ## Resume Semantics
The versioned semantic fingerprint covers the Audita service endpoint, model, Version 2 semantic evidence covers Audita's config path, downstream overlay,
validation model, module set, transcript description, output schema, selected proposal and validation profile selectors, ordered module selection (including
external configuration path, and canonical polished-transcript identity. Module the distinction between omission and an explicit empty list), transcript
ordering is normalized because the configured modules form a set. Audita's description, output schema, and canonical polished-transcript identity.
executable, timeouts, concurrency, report and debug behavior, work retention, Credentials, executable path, timeout, and diagnostics are operational and do
and resolved credential value are operational and do not invalidate a not invalidate a successful result. Public credential reference names may
successful result. The public credential reference name may participate in participate in configuration provenance but never expose resolved values.
configuration provenance but never exposes its resolved value.
Narratio can fingerprint a selected model, module, or configuration identifier, Narratio fingerprints selections, not the contents of Audita-owned config or
but it cannot inspect content that Audita privately resolves behind that stable profiles. Force `polish` after changing those files behind unchanged selections.
identifier. Force `polish` after changing such private content without changing When polish is selected, older semantic evidence is reconsidered and the stage
its identifier. reruns; a downstream-only invocation does not eagerly run polish.
## Related Contracts And Tests ## Related Contracts And Tests

View File

@@ -354,7 +354,10 @@ promote an incidental transcript.
## Stage 6: Complete semantic evidence and resume behavior ## Stage 6: Complete semantic evidence and resume behavior
Status: Not started. Status: Complete (2026-09-25).
Verification: `go test ./internal/stage ./internal/app`,
`go test -race ./internal/stage ./internal/app`, and `git diff --check` passed.
Preparation: `internal/stage/semantic_contracts_refinement.go`, semantic Preparation: `internal/stage/semantic_contracts_refinement.go`, semantic
fingerprint tests, app runner semantic comparison tests, and extraction semantic fingerprint tests, app runner semantic comparison tests, and extraction semantic

View File

@@ -373,7 +373,7 @@ func TestAssembledSplitBundleProfileSwitchRecordsProvenanceAndLimitsReuse(t *tes
if err != nil { if err != nil {
t.Fatalf("switch to testing profile: %v", err) t.Fatalf("switch to testing profile: %v", err)
} }
if got, want := testingRuns, []int{0, 0, 0, 0, 0, 0, 0}; !reflect.DeepEqual(got, want) { if got, want := testingRuns, []int{0, 0, 0, 1, 1, 1, 1}; !reflect.DeepEqual(got, want) {
t.Fatalf("testing profile runs = %v, want %v", got, want) t.Fatalf("testing profile runs = %v, want %v", got, want)
} }
assertAssembledProvenance(t, store, testingSummary, "testing", config.EffectivePipelineDigest(testingCfg.Pipeline)) assertAssembledProvenance(t, store, testingSummary, "testing", config.EffectivePipelineDigest(testingCfg.Pipeline))

View File

@@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors" "errors"
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -424,6 +425,69 @@ func TestRefinementSemanticChangesRespectDependencyBranches(t *testing.T) {
} }
} }
func TestOldPolishSemanticEvidenceIsReconsideredOnlyWhenSelected(t *testing.T) {
cfg := testConfig(t)
names := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render"}
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
for _, candidate := range stage.All() {
seed.MarkStageSucceeded(candidate.Name(), time.Now().UTC(), nil)
}
providers := make([]stage.SemanticConfigFingerprinter, len(names))
runs := make([]int, len(names))
selected := make([]stage.Stage, 0, len(names))
for index, name := range names {
providers[index] = canonicalSemanticProvider(t, name)
fingerprint, err := providers[index].SemanticConfigFingerprint(&stage.Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if name == "polish" {
fingerprint.Version--
}
seed.Stages[name].SemanticConfig = &fingerprint
selected = append(selected, semanticContractRunStub{name: name, provider: providers[index], runs: &runs[index]})
}
store := &manifest.LocalStore{}
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
t.Fatal(err)
}
if _, err := executeStages(context.Background(), cfg, selected[len(selected)-1:], RunOptions{}); err != nil {
t.Fatal(err)
}
if runs[3] != 0 {
t.Fatal("downstream-only execution invoked polish")
}
loaded, err := store.Load(context.Background(), manifestPathFor(cfg))
if err != nil {
t.Fatal(err)
}
if loaded.Stages["polish"].SemanticConfig.Version != seed.Stages["polish"].SemanticConfig.Version {
t.Fatal("downstream-only execution rewrote unselected polish evidence")
}
if _, err := executeStages(context.Background(), cfg, selected, RunOptions{}); err != nil {
t.Fatal(err)
}
if want := []int{0, 0, 0, 1, 1, 1, 1}; !reflect.DeepEqual(runs, want) {
t.Fatalf("runs = %v, want %v", runs, want)
}
loaded, err = store.Load(context.Background(), manifestPathFor(cfg))
if err != nil {
t.Fatal(err)
}
if loaded.Stages["polish"].SemanticConfig.Version != 2 || loaded.Stages["extract"].Status != manifest.StatusStale {
t.Fatalf("old evidence was not replaced with normal dependent invalidation: polish=%#v extract=%#v", loaded.Stages["polish"], loaded.Stages["extract"])
}
for index := range runs {
runs[index] = 0
}
if _, err := executeStages(context.Background(), cfg, selected, RunOptions{}); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(runs, make([]int, len(names))) {
t.Fatalf("unchanged new evidence reran stages: %v", runs)
}
}
func TestDeliverySemanticChangesInvalidateOnlyTheirFixedDependents(t *testing.T) { func TestDeliverySemanticChangesInvalidateOnlyTheirFixedDependents(t *testing.T) {
t.Run("extract contract", func(t *testing.T) { t.Run("extract contract", func(t *testing.T) {
cfg := testConfig(t) cfg := testConfig(t)

View File

@@ -192,6 +192,7 @@ func TestExtractSemanticConfigExcludesLoadedCredentialChanges(t *testing.T) {
first := loadNotariusCredentialForSemanticTest(t, firstName, "first-value") first := loadNotariusCredentialForSemanticTest(t, firstName, "first-value")
rotated := loadNotariusCredentialForSemanticTest(t, firstName, "rotated-value") rotated := loadNotariusCredentialForSemanticTest(t, firstName, "rotated-value")
renamed := loadNotariusCredentialForSemanticTest(t, secondName, "second-value") renamed := loadNotariusCredentialForSemanticTest(t, secondName, "second-value")
third := loadNotariusCredentialForSemanticTest(t, "NARRATIO_SEMANTIC_NOTARIUS_KEY_THREE", "third-value")
baseline := downstreamSemanticEnv() baseline := downstreamSemanticEnv()
baseline.Config.Pipeline.Notarius.Credentials = []configkit.Secret{*first} baseline.Config.Pipeline.Notarius.Credentials = []configkit.Secret{*first}
@@ -221,6 +222,14 @@ func TestExtractSemanticConfigExcludesLoadedCredentialChanges(t *testing.T) {
} }
}) })
} }
baseline.Config.Pipeline.Notarius.Credentials = []configkit.Secret{*renamed, *third}
multiple, err := (extractStage{}).SemanticConfigFingerprint(baseline)
if err != nil {
t.Fatal(err)
}
if !want.Equal(multiple) {
t.Fatal("multiple credential references altered extract semantic fingerprint")
}
} }
func loadNotariusCredentialForSemanticTest(t *testing.T, name, value string) *configkit.Secret { func loadNotariusCredentialForSemanticTest(t *testing.T, name, value string) *configkit.Secret {

View File

@@ -2,7 +2,6 @@ package stage
import ( import (
"fmt" "fmt"
"sort"
"strings" "strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/artifacts"
@@ -11,7 +10,7 @@ import (
) )
const ( const (
polishSemanticConfigVersion = 1 polishSemanticConfigVersion = 2
normalizeSemanticConfigVersion = 1 normalizeSemanticConfigVersion = 1
trimSemanticConfigVersion = 1 trimSemanticConfigVersion = 1
renderSemanticConfigVersion = 1 renderSemanticConfigVersion = 1
@@ -19,9 +18,12 @@ const (
type polishSemanticConfig struct { type polishSemanticConfig struct {
Modules []string `json:"modules"` Modules []string `json:"modules"`
Overlay string `json:"overlay"`
ProposalProfile string `json:"proposal_profile"`
ValidationProfile string `json:"validation_profile"`
TranscriptDescription string `json:"transcript_description"` TranscriptDescription string `json:"transcript_description"`
OutputSchema string `json:"output_schema"` OutputSchema string `json:"output_schema"`
ExternalConfig string `json:"external_config"` ConfigPath string `json:"config_path"`
OutputIdentity string `json:"output_identity"` OutputIdentity string `json:"output_identity"`
} }
@@ -65,16 +67,21 @@ func (polishStage) SemanticConfigFingerprint(env *Env) (manifest.SemanticConfigF
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("polish semantic configuration requires resolved pipeline config") return manifest.SemanticConfigFingerprint{}, fmt.Errorf("polish semantic configuration requires resolved pipeline config")
} }
audita := env.Config.Pipeline.Audita audita := env.Config.Pipeline.Audita
modules := append([]string(nil), audita.Modules...) var modules []string
if audita.Modules != nil {
modules = append([]string{}, audita.Modules...)
}
for index := range modules { for index := range modules {
modules[index] = strings.TrimSpace(modules[index]) modules[index] = strings.TrimSpace(modules[index])
} }
sort.Strings(modules)
return FingerprintSemanticConfig(polishSemanticConfigVersion, polishSemanticConfig{ return FingerprintSemanticConfig(polishSemanticConfigVersion, polishSemanticConfig{
Modules: modules, Modules: modules,
TranscriptDescription: strings.TrimSpace(audita.TranscriptDescription), Overlay: audita.Overlay,
OutputSchema: strings.TrimSpace(audita.OutputSchema), ProposalProfile: audita.ProposalProfile,
ExternalConfig: strings.TrimSpace(audita.ConfigPath), ValidationProfile: audita.ValidationProfile,
TranscriptDescription: audita.TranscriptDescription,
OutputSchema: audita.OutputSchema,
ConfigPath: audita.ConfigPath,
OutputIdentity: artifacts.ArtifactTranscriptPolished, OutputIdentity: artifacts.ArtifactTranscriptPolished,
}) })
} }

View File

@@ -13,6 +13,13 @@ func TestPolishSemanticConfigSensitivity(t *testing.T) {
mutate func(*Env) mutate func(*Env)
}{ }{
{name: "modules", mutate: func(env *Env) { env.Config.Pipeline.Audita.Modules = []string{"terms", "speakers", "style"} }}, {name: "modules", mutate: func(env *Env) { env.Config.Pipeline.Audita.Modules = []string{"terms", "speakers", "style"} }},
{name: "module order", mutate: func(env *Env) { env.Config.Pipeline.Audita.Modules = []string{"terms", "speakers"} }},
{name: "module repetition", mutate: func(env *Env) { env.Config.Pipeline.Audita.Modules = []string{"speakers", "terms", "terms"} }},
{name: "modules omitted", mutate: func(env *Env) { env.Config.Pipeline.Audita.Modules = nil }},
{name: "modules empty", mutate: func(env *Env) { env.Config.Pipeline.Audita.Modules = []string{} }},
{name: "overlay", mutate: func(env *Env) { env.Config.Pipeline.Audita.Overlay = "testing" }},
{name: "proposal profile", mutate: func(env *Env) { env.Config.Pipeline.Audita.ProposalProfile = "draft" }},
{name: "validation profile", mutate: func(env *Env) { env.Config.Pipeline.Audita.ValidationProfile = "review" }},
{name: "description", mutate: func(env *Env) { env.Config.Pipeline.Audita.TranscriptDescription = "live campaign" }}, {name: "description", mutate: func(env *Env) { env.Config.Pipeline.Audita.TranscriptDescription = "live campaign" }},
{name: "schema", mutate: func(env *Env) { env.Config.Pipeline.Audita.OutputSchema = "audita.transcript.v2" }}, {name: "schema", mutate: func(env *Env) { env.Config.Pipeline.Audita.OutputSchema = "audita.transcript.v2" }},
{name: "external config", mutate: func(env *Env) { env.Config.Pipeline.Audita.ConfigPath = "configs/other.yml" }}, {name: "external config", mutate: func(env *Env) { env.Config.Pipeline.Audita.ConfigPath = "configs/other.yml" }},
@@ -22,7 +29,6 @@ func TestPolishSemanticConfigSensitivity(t *testing.T) {
name string name string
mutate func(*Env) mutate func(*Env)
}{ }{
{name: "module order", mutate: func(env *Env) { env.Config.Pipeline.Audita.Modules = []string{"terms", "speakers"} }},
{name: "runner settings", mutate: func(env *Env) { {name: "runner settings", mutate: func(env *Env) {
env.Config.Pipeline.Audita.Binary = "/opt/audita" env.Config.Pipeline.Audita.Binary = "/opt/audita"
env.Config.Pipeline.Audita.Timeout = "1h" env.Config.Pipeline.Audita.Timeout = "1h"
@@ -31,6 +37,23 @@ func TestPolishSemanticConfigSensitivity(t *testing.T) {
}) })
} }
func TestPolishSemanticConfigDistinguishesOmittedAndExplicitlyEmptyModules(t *testing.T) {
env := refinementSemanticEnv()
env.Config.Pipeline.Audita.Modules = nil
omitted, err := (polishStage{}).SemanticConfigFingerprint(env)
if err != nil {
t.Fatal(err)
}
env.Config.Pipeline.Audita.Modules = []string{}
empty, err := (polishStage{}).SemanticConfigFingerprint(env)
if err != nil {
t.Fatal(err)
}
if omitted.Equal(empty) {
t.Fatal("omitted and explicitly empty module selections have equal semantic evidence")
}
}
func TestNormalizeSemanticConfigSensitivity(t *testing.T) { func TestNormalizeSemanticConfigSensitivity(t *testing.T) {
assertRefinementChanges(t, normalizeStage{}, []struct { assertRefinementChanges(t, normalizeStage{}, []struct {
name string name string