From 2d167d0d67f2c86ae73eedeea39acc5a9fdb48fb Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 25 Sep 2026 15:55:38 +0000 Subject: [PATCH] Track Audita v2 selections in polish resume evidence --- docs/internal/stage-polish.md | 23 ++++--- docs/roadmap/implementation.md | 5 +- internal/app/assembled_workflow_test.go | 2 +- internal/app/semantic_resume_test.go | 64 +++++++++++++++++++ .../stage/semantic_contracts_delivery_test.go | 9 +++ .../stage/semantic_contracts_refinement.go | 23 ++++--- .../semantic_contracts_refinement_test.go | 25 +++++++- 7 files changed, 128 insertions(+), 23 deletions(-) diff --git a/docs/internal/stage-polish.md b/docs/internal/stage-polish.md index 14da9a6..c04d5b6 100644 --- a/docs/internal/stage-polish.md +++ b/docs/internal/stage-polish.md @@ -35,19 +35,18 @@ are recorded as diagnostics, not artifact outputs. ## Resume Semantics -The versioned semantic fingerprint covers the Audita service endpoint, model, -validation model, module set, transcript description, output schema, selected -external configuration path, and canonical polished-transcript identity. Module -ordering is normalized because the configured modules form a set. Audita's -executable, timeouts, concurrency, report and debug behavior, work retention, -and resolved credential value are operational and do not invalidate a -successful result. The public credential reference name may participate in -configuration provenance but never exposes its resolved value. +Version 2 semantic evidence covers Audita's config path, downstream overlay, +proposal and validation profile selectors, ordered module selection (including +the distinction between omission and an explicit empty list), transcript +description, output schema, and canonical polished-transcript identity. +Credentials, executable path, timeout, and diagnostics are operational and do +not invalidate a successful result. Public credential reference names may +participate in configuration provenance but never expose resolved values. -Narratio can fingerprint a selected model, module, or configuration identifier, -but it cannot inspect content that Audita privately resolves behind that stable -identifier. Force `polish` after changing such private content without changing -its identifier. +Narratio fingerprints selections, not the contents of Audita-owned config or +profiles. Force `polish` after changing those files behind unchanged selections. +When polish is selected, older semantic evidence is reconsidered and the stage +reruns; a downstream-only invocation does not eagerly run polish. ## Related Contracts And Tests diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 4894b86..b7b0a9f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -354,7 +354,10 @@ promote an incidental transcript. ## 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 fingerprint tests, app runner semantic comparison tests, and extraction semantic diff --git a/internal/app/assembled_workflow_test.go b/internal/app/assembled_workflow_test.go index 1f63323..b8e26a1 100644 --- a/internal/app/assembled_workflow_test.go +++ b/internal/app/assembled_workflow_test.go @@ -373,7 +373,7 @@ func TestAssembledSplitBundleProfileSwitchRecordsProvenanceAndLimitsReuse(t *tes if err != nil { 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) } assertAssembledProvenance(t, store, testingSummary, "testing", config.EffectivePipelineDigest(testingCfg.Pipeline)) diff --git a/internal/app/semantic_resume_test.go b/internal/app/semantic_resume_test.go index 61785be..3abcd60 100644 --- a/internal/app/semantic_resume_test.go +++ b/internal/app/semantic_resume_test.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "reflect" "strings" "testing" "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) { t.Run("extract contract", func(t *testing.T) { cfg := testConfig(t) diff --git a/internal/stage/semantic_contracts_delivery_test.go b/internal/stage/semantic_contracts_delivery_test.go index 2c86282..d20acc5 100644 --- a/internal/stage/semantic_contracts_delivery_test.go +++ b/internal/stage/semantic_contracts_delivery_test.go @@ -192,6 +192,7 @@ func TestExtractSemanticConfigExcludesLoadedCredentialChanges(t *testing.T) { first := loadNotariusCredentialForSemanticTest(t, firstName, "first-value") rotated := loadNotariusCredentialForSemanticTest(t, firstName, "rotated-value") renamed := loadNotariusCredentialForSemanticTest(t, secondName, "second-value") + third := loadNotariusCredentialForSemanticTest(t, "NARRATIO_SEMANTIC_NOTARIUS_KEY_THREE", "third-value") baseline := downstreamSemanticEnv() 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 { diff --git a/internal/stage/semantic_contracts_refinement.go b/internal/stage/semantic_contracts_refinement.go index 1e9c744..093bb8f 100644 --- a/internal/stage/semantic_contracts_refinement.go +++ b/internal/stage/semantic_contracts_refinement.go @@ -2,7 +2,6 @@ package stage import ( "fmt" - "sort" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" @@ -11,7 +10,7 @@ import ( ) const ( - polishSemanticConfigVersion = 1 + polishSemanticConfigVersion = 2 normalizeSemanticConfigVersion = 1 trimSemanticConfigVersion = 1 renderSemanticConfigVersion = 1 @@ -19,9 +18,12 @@ const ( type polishSemanticConfig struct { Modules []string `json:"modules"` + Overlay string `json:"overlay"` + ProposalProfile string `json:"proposal_profile"` + ValidationProfile string `json:"validation_profile"` TranscriptDescription string `json:"transcript_description"` OutputSchema string `json:"output_schema"` - ExternalConfig string `json:"external_config"` + ConfigPath string `json:"config_path"` 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") } 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 { modules[index] = strings.TrimSpace(modules[index]) } - sort.Strings(modules) return FingerprintSemanticConfig(polishSemanticConfigVersion, polishSemanticConfig{ Modules: modules, - TranscriptDescription: strings.TrimSpace(audita.TranscriptDescription), - OutputSchema: strings.TrimSpace(audita.OutputSchema), - ExternalConfig: strings.TrimSpace(audita.ConfigPath), + Overlay: audita.Overlay, + ProposalProfile: audita.ProposalProfile, + ValidationProfile: audita.ValidationProfile, + TranscriptDescription: audita.TranscriptDescription, + OutputSchema: audita.OutputSchema, + ConfigPath: audita.ConfigPath, OutputIdentity: artifacts.ArtifactTranscriptPolished, }) } diff --git a/internal/stage/semantic_contracts_refinement_test.go b/internal/stage/semantic_contracts_refinement_test.go index b6a7eeb..e2763c2 100644 --- a/internal/stage/semantic_contracts_refinement_test.go +++ b/internal/stage/semantic_contracts_refinement_test.go @@ -13,6 +13,13 @@ func TestPolishSemanticConfigSensitivity(t *testing.T) { mutate func(*Env) }{ {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: "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" }}, @@ -22,7 +29,6 @@ func TestPolishSemanticConfigSensitivity(t *testing.T) { name string 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) { env.Config.Pipeline.Audita.Binary = "/opt/audita" 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) { assertRefinementChanges(t, normalizeStage{}, []struct { name string