Add incremental analysis work planning

This commit is contained in:
2026-08-29 19:26:10 +00:00
parent c32e0c401f
commit 6abdd67bb5
4 changed files with 589 additions and 1 deletions

View File

@@ -0,0 +1,231 @@
package stage
import (
"fmt"
"sort"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
type analyzePlanRole string
const (
analyzePlanTarget analyzePlanRole = "target"
analyzePlanPrerequisite analyzePlanRole = "prerequisite"
)
type analyzePlanAction string
const (
analyzePlanExecute analyzePlanAction = "execute"
analyzePlanReuse analyzePlanAction = "reuse"
)
type analyzePlanItem struct {
Key string
Role analyzePlanRole
Action analyzePlanAction
Reason analyzeReconciliationReason
Forced bool
Dependencies []string
ExpectedFingerprint string
}
type analyzeWorkPlan struct {
ExplicitTargets []string
PrerequisiteWork []analyzePlanItem
ExecutionOrder []analyzePlanItem
ReusedCurrent []analyzePlanItem
Invalidated []analyzePlanItem
Removed []analyzePlanItem
ProjectedRecords map[string]manifest.AnalyzeArtifactRecord
}
// planAnalyzeWork turns selection and reconciliation into one deterministic,
// read-only work plan. It never invokes adapters or mutates manifest state.
func planAnalyzeWork(
scriptoriumCfg *config.ScriptoriumConfig,
selected []string,
force bool,
reconciliation analyzeReconciliation,
) (analyzeWorkPlan, error) {
if scriptoriumCfg == nil {
return analyzeWorkPlan{}, nil
}
if err := config.ValidateScriptoriumArtifactDependencies(scriptoriumCfg.Artifacts); err != nil {
return analyzeWorkPlan{}, err
}
effective, err := artifacts.ResolveEffectiveArtifactSet(
artifacts.ConfiguredArtifactDefinitions(scriptoriumCfg.Artifacts),
selected,
)
if err != nil {
return analyzeWorkPlan{}, err
}
targets := effective.Keys()
targetSet := stringSet(targets)
closure, err := analyzeTargetDependencyClosure(scriptoriumCfg.Artifacts, targets)
if err != nil {
return analyzeWorkPlan{}, err
}
order, err := orderConfiguredAnalyzeArtifacts(scriptoriumCfg.Artifacts)
if err != nil {
return analyzeWorkPlan{}, err
}
reconciled := make(map[string]analyzeArtifactReconciliation, len(reconciliation.Ordered))
removed := make([]analyzePlanItem, 0)
for _, item := range reconciliation.Ordered {
if item.Reason == analyzeReconciliationRemoved {
removed = append(removed, analyzePlanItem{Key: item.Key, Reason: item.Reason})
continue
}
reconciled[item.Key] = item
}
sort.Slice(removed, func(i, j int) bool { return removed[i].Key < removed[j].Key })
plan := analyzeWorkPlan{
ExplicitTargets: append([]string(nil), targets...),
Removed: removed,
ProjectedRecords: projectedAnalyzeRecords(scriptoriumCfg.Artifacts, reconciliation),
}
invalidated := make(map[string]struct{})
for _, state := range reconciliation.Ordered {
if _, configured := scriptoriumCfg.Artifacts[state.Key]; !configured || state.Stored == nil {
continue
}
if state.Stored.Status == manifest.AnalyzeArtifactCurrent && state.Reason != analyzeReconciliationCurrent {
plan.Invalidated = append(plan.Invalidated, analyzePlanItem{
Key: state.Key, Reason: state.Reason, ExpectedFingerprint: state.ExpectedFingerprint,
})
invalidated[state.Key] = struct{}{}
}
}
scheduled := make(map[string]bool, len(closure))
for _, key := range order {
if _, included := closure[key]; !included {
continue
}
state, ok := reconciled[key]
if !ok {
return analyzeWorkPlan{}, fmt.Errorf("analysis reconciliation is missing configured artifact %q", key)
}
dependencies := normalizedAnalyzeDependencyKeys(scriptoriumCfg.Artifacts[key].DependsOn)
dependencyWork := false
for _, dependency := range dependencies {
dependencyWork = dependencyWork || scheduled[dependency]
}
_, explicit := targetSet[key]
forced := force && explicit
needsWork := forced || state.Reason != analyzeReconciliationCurrent || dependencyWork
item := analyzePlanItem{
Key: key, Reason: state.Reason, Forced: forced,
Dependencies: append([]string(nil), dependencies...),
ExpectedFingerprint: state.ExpectedFingerprint,
}
if explicit {
item.Role = analyzePlanTarget
} else {
item.Role = analyzePlanPrerequisite
}
if needsWork {
item.Action = analyzePlanExecute
scheduled[key] = true
plan.ExecutionOrder = append(plan.ExecutionOrder, item)
if !explicit {
plan.PrerequisiteWork = append(plan.PrerequisiteWork, item)
}
if state.Stored != nil && state.Stored.Status == manifest.AnalyzeArtifactCurrent {
if _, exists := invalidated[key]; !exists {
plan.Invalidated = append(plan.Invalidated, item)
invalidated[key] = struct{}{}
}
staleProjectedAnalyzeRecord(plan.ProjectedRecords, key)
}
continue
}
item.Action = analyzePlanReuse
plan.ReusedCurrent = append(plan.ReusedCurrent, item)
}
sort.Slice(plan.Invalidated, func(i, j int) bool { return plan.Invalidated[i].Key < plan.Invalidated[j].Key })
return plan, nil
}
func analyzeTargetDependencyClosure(
configured map[string]config.ScriptoriumArtifactConfig,
targets []string,
) (map[string]struct{}, error) {
closure := make(map[string]struct{}, len(targets))
var visit func(string) error
visit = func(key string) error {
if _, seen := closure[key]; seen {
return nil
}
artifactCfg, ok := configured[key]
if !ok {
return fmt.Errorf("selected artifact %q is not configured", key)
}
closure[key] = struct{}{}
for _, dependency := range normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn) {
if err := visit(dependency); err != nil {
return err
}
}
return nil
}
for _, target := range targets {
if err := visit(target); err != nil {
return nil, err
}
}
return closure, nil
}
func projectedAnalyzeRecords(
configured map[string]config.ScriptoriumArtifactConfig,
reconciliation analyzeReconciliation,
) map[string]manifest.AnalyzeArtifactRecord {
projected := make(map[string]manifest.AnalyzeArtifactRecord)
for _, item := range reconciliation.Ordered {
if item.Stored == nil || item.Reason == analyzeReconciliationRemoved {
continue
}
if _, exists := configured[item.Key]; !exists {
continue
}
record := *item.Stored
if err := manifest.ValidateAnalyzeArtifactCollection(
manifest.AnalyzeStateContractVersion,
map[string]manifest.AnalyzeArtifactRecord{item.Key: record},
); err != nil {
continue
}
projected[item.Key] = record
if item.Reason != analyzeReconciliationCurrent && record.Status == manifest.AnalyzeArtifactCurrent {
staleProjectedAnalyzeRecord(projected, item.Key)
}
}
return manifest.CloneAnalyzeArtifactCollection(projected)
}
func staleProjectedAnalyzeRecord(records map[string]manifest.AnalyzeArtifactRecord, key string) {
record, ok := records[key]
if !ok || record.Status != manifest.AnalyzeArtifactCurrent {
return
}
record.Status = manifest.AnalyzeArtifactStale
record.Output = nil
record.OutputSize = 0
record.Error = ""
records[key] = record
}
func stringSet(values []string) map[string]struct{} {
result := make(map[string]struct{}, len(values))
for _, value := range values {
result[value] = struct{}{}
}
return result
}

View File

@@ -0,0 +1,341 @@
package stage
import (
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestPlanAnalyzeWorkConsumesSemanticReconciliation(t *testing.T) {
env, m := currentAnalyzeReconciliationFixture(t)
reconciled, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m))
if err != nil {
t.Fatal(err)
}
plan, err := planAnalyzeWork(env.Config.Pipeline.Scriptorium, nil, false, reconciled)
if err != nil {
t.Fatal(err)
}
assertAnalyzePlanKeys(t, "current execution", plan.ExecutionOrder, nil)
assertAnalyzePlanKeys(t, "current reuse", plan.ReusedCurrent, []string{"session_recap"})
paths := sessionPathsForEnv(env, m.SessionID)
recordPreparedAnalyzeInput(t, m, "narratio.input.players", filepath.Join(paths.InputsDir, "players.yml"), "players:\n - Hrank\n")
reconciled, err = reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m))
if err != nil {
t.Fatal(err)
}
plan, err = planAnalyzeWork(env.Config.Pipeline.Scriptorium, nil, false, reconciled)
if err != nil {
t.Fatal(err)
}
assertAnalyzePlanKeys(t, "changed execution", plan.ExecutionOrder, []string{"session_recap"})
}
func TestPlanAnalyzeWorkSelectionAndDependencyClosure(t *testing.T) {
tests := []struct {
name string
configured map[string]config.ScriptoriumArtifactConfig
selected []string
force bool
reasons map[string]analyzeReconciliationReason
wantTargets []string
wantExecution []string
wantPrerequisiteWork []string
wantReused []string
wantForced []string
}{
{
name: "default selection targets every enabled artifact",
configured: map[string]config.ScriptoriumArtifactConfig{
"alpha": {Enabled: true}, "zeta": {Enabled: true}, "disabled": {Enabled: false},
},
reasons: map[string]analyzeReconciliationReason{
"alpha": analyzeReconciliationMissing, "zeta": analyzeReconciliationMissing, "disabled": analyzeReconciliationMissing,
},
wantTargets: []string{"alpha", "zeta"}, wantExecution: []string{"alpha", "zeta"},
},
{
name: "partial selection closes over nested dependencies",
configured: map[string]config.ScriptoriumArtifactConfig{
"base": {Enabled: false}, "middle": {Enabled: false, DependsOn: []string{"base"}},
"target": {Enabled: true, DependsOn: []string{"middle"}}, "unrelated": {Enabled: true},
},
selected: []string{"target"},
reasons: map[string]analyzeReconciliationReason{
"base": analyzeReconciliationMissing, "middle": analyzeReconciliationMissing,
"target": analyzeReconciliationMissing, "unrelated": analyzeReconciliationMissing,
},
wantTargets: []string{"target"}, wantExecution: []string{"base", "middle", "target"},
wantPrerequisiteWork: []string{"base", "middle"},
},
{
name: "current prerequisite is reused",
configured: map[string]config.ScriptoriumArtifactConfig{
"base": {Enabled: false}, "target": {Enabled: true, DependsOn: []string{"base"}},
},
selected: []string{"target"}, reasons: map[string]analyzeReconciliationReason{
"base": analyzeReconciliationCurrent, "target": analyzeReconciliationStale,
},
wantTargets: []string{"target"}, wantExecution: []string{"target"}, wantReused: []string{"base"},
},
{
name: "stale prerequisite schedules dependent after it",
configured: map[string]config.ScriptoriumArtifactConfig{
"base": {Enabled: false}, "target": {Enabled: true, DependsOn: []string{"base"}},
},
selected: []string{"target"}, reasons: map[string]analyzeReconciliationReason{
"base": analyzeReconciliationStale, "target": analyzeReconciliationCurrent,
},
wantTargets: []string{"target"}, wantExecution: []string{"base", "target"},
wantPrerequisiteWork: []string{"base"},
},
{
name: "force applies only to explicit target",
configured: map[string]config.ScriptoriumArtifactConfig{
"base": {Enabled: false}, "target": {Enabled: true, DependsOn: []string{"base"}},
},
selected: []string{"target"}, force: true, reasons: map[string]analyzeReconciliationReason{
"base": analyzeReconciliationCurrent, "target": analyzeReconciliationCurrent,
},
wantTargets: []string{"target"}, wantExecution: []string{"target"},
wantReused: []string{"base"}, wantForced: []string{"target"},
},
{
name: "explicit disabled target is valid",
configured: map[string]config.ScriptoriumArtifactConfig{
"disabled": {Enabled: false}, "unrelated": {Enabled: false},
},
selected: []string{"disabled"}, reasons: map[string]analyzeReconciliationReason{
"disabled": analyzeReconciliationMissing, "unrelated": analyzeReconciliationMissing,
},
wantTargets: []string{"disabled"}, wantExecution: []string{"disabled"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reconciliation := analyzePlanningReconciliation(tt.configured, tt.reasons, nil)
plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: tt.configured}, tt.selected, tt.force, reconciliation)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(plan.ExplicitTargets, tt.wantTargets) {
t.Fatalf("targets = %#v, want %#v", plan.ExplicitTargets, tt.wantTargets)
}
assertAnalyzePlanKeys(t, "execution", plan.ExecutionOrder, tt.wantExecution)
assertAnalyzePlanKeys(t, "prerequisite work", plan.PrerequisiteWork, tt.wantPrerequisiteWork)
assertAnalyzePlanKeys(t, "reused", plan.ReusedCurrent, tt.wantReused)
forced := make([]string, 0)
for _, item := range plan.ExecutionOrder {
if item.Forced {
forced = append(forced, item.Key)
}
}
if !(len(forced) == 0 && len(tt.wantForced) == 0) && !reflect.DeepEqual(forced, tt.wantForced) {
t.Fatalf("forced = %#v, want %#v", forced, tt.wantForced)
}
})
}
}
func TestPlanAnalyzeWorkSchedulesEveryNonCurrentReason(t *testing.T) {
reasons := []analyzeReconciliationReason{
analyzeReconciliationStale,
analyzeReconciliationMissing,
analyzeReconciliationFailed,
analyzeReconciliationLegacy,
analyzeReconciliationNonResumable,
}
for _, reason := range reasons {
t.Run(string(reason), func(t *testing.T) {
configured := map[string]config.ScriptoriumArtifactConfig{"target": {Enabled: true}}
plan, err := planAnalyzeWork(
&config.ScriptoriumConfig{Artifacts: configured}, nil, false,
analyzePlanningReconciliation(configured, map[string]analyzeReconciliationReason{"target": reason}, nil),
)
if err != nil {
t.Fatal(err)
}
assertAnalyzePlanKeys(t, "execution", plan.ExecutionOrder, []string{"target"})
if plan.ExecutionOrder[0].Reason != reason {
t.Fatalf("reason = %q, want %q", plan.ExecutionOrder[0].Reason, reason)
}
})
}
}
func TestPlanAnalyzeWorkRejectsUnknownSelectionAndInvalidGraphs(t *testing.T) {
tests := []struct {
name string
configured map[string]config.ScriptoriumArtifactConfig
selected []string
want string
}{
{
name: "unknown selection", configured: map[string]config.ScriptoriumArtifactConfig{"target": {Enabled: true}},
selected: []string{"missing"}, want: "not configured",
},
{
name: "unknown dependency", configured: map[string]config.ScriptoriumArtifactConfig{"target": {Enabled: true, DependsOn: []string{"missing"}}},
want: "is not configured",
},
{
name: "cycle", configured: map[string]config.ScriptoriumArtifactConfig{
"alpha": {Enabled: true, DependsOn: []string{"beta"}}, "beta": {Enabled: false, DependsOn: []string{"alpha"}},
},
want: "must not contain cycles",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: tt.configured}, tt.selected, false, analyzeReconciliation{})
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error = %v, want %q", err, tt.want)
}
})
}
}
func TestPlanAnalyzeWorkProjectionPreservesOnlyValidConfiguredAuthority(t *testing.T) {
configured := map[string]config.ScriptoriumArtifactConfig{
"target": {Enabled: true}, "unrelated": {Enabled: true}, "invalid_unselected": {Enabled: true},
}
reasons := map[string]analyzeReconciliationReason{
"target": analyzeReconciliationMissing, "unrelated": analyzeReconciliationCurrent,
"invalid_unselected": analyzeReconciliationStale,
}
stored := map[string]*manifest.AnalyzeArtifactRecord{
"unrelated": analyzePlanningCurrentRecord("unrelated"),
"invalid_unselected": analyzePlanningCurrentRecord("invalid_unselected"),
}
reconciliation := analyzePlanningReconciliation(configured, reasons, stored)
removed := analyzePlanningCurrentRecord("removed")
reconciliation.Ordered = append(reconciliation.Ordered, analyzeArtifactReconciliation{
Key: "removed", Reason: analyzeReconciliationRemoved, Stored: removed,
})
before := manifest.CloneAnalyzeArtifactCollection(map[string]manifest.AnalyzeArtifactRecord{
"unrelated": *stored["unrelated"], "invalid_unselected": *stored["invalid_unselected"], "removed": *removed,
})
plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: configured}, []string{"target"}, false, reconciliation)
if err != nil {
t.Fatal(err)
}
if got := plan.ProjectedRecords["unrelated"].Status; got != manifest.AnalyzeArtifactCurrent {
t.Fatalf("unrelated status = %q, want current", got)
}
if got := plan.ProjectedRecords["invalid_unselected"].Status; got != manifest.AnalyzeArtifactStale {
t.Fatalf("invalid unselected status = %q, want stale", got)
}
if _, exists := plan.ProjectedRecords["removed"]; exists {
t.Fatal("removed record survived projected authority")
}
assertAnalyzePlanKeys(t, "invalidated", plan.Invalidated, []string{"invalid_unselected"})
assertAnalyzePlanKeys(t, "removed", plan.Removed, []string{"removed"})
if !reflect.DeepEqual(before["unrelated"], *stored["unrelated"]) || !reflect.DeepEqual(before["invalid_unselected"], *stored["invalid_unselected"]) {
t.Fatal("planning mutated stored records")
}
}
func TestPlanAnalyzeWorkLegacyPartialSelectionDoesNotPromoteUnselectedOutput(t *testing.T) {
configured := map[string]config.ScriptoriumArtifactConfig{
"target": {Enabled: true}, "legacy_unselected": {Enabled: true},
}
reconciliation := analyzePlanningReconciliation(configured, map[string]analyzeReconciliationReason{
"target": analyzeReconciliationLegacy, "legacy_unselected": analyzeReconciliationLegacy,
}, nil)
plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: configured}, []string{"target"}, false, reconciliation)
if err != nil {
t.Fatal(err)
}
assertAnalyzePlanKeys(t, "execution", plan.ExecutionOrder, []string{"target"})
if len(plan.ProjectedRecords) != 0 {
t.Fatalf("legacy projection = %#v, want no current records", plan.ProjectedRecords)
}
}
func TestPlanAnalyzeWorkOrderIsDeterministicAcrossMapInsertion(t *testing.T) {
want := []string{"alpha", "middle", "zeta"}
for attempt := 0; attempt < 50; attempt++ {
configured := make(map[string]config.ScriptoriumArtifactConfig, 3)
if attempt%2 == 0 {
configured["zeta"] = config.ScriptoriumArtifactConfig{Enabled: true, DependsOn: []string{"middle"}}
configured["middle"] = config.ScriptoriumArtifactConfig{Enabled: false, DependsOn: []string{"alpha"}}
configured["alpha"] = config.ScriptoriumArtifactConfig{Enabled: false}
} else {
configured["alpha"] = config.ScriptoriumArtifactConfig{Enabled: false}
configured["middle"] = config.ScriptoriumArtifactConfig{Enabled: false, DependsOn: []string{"alpha"}}
configured["zeta"] = config.ScriptoriumArtifactConfig{Enabled: true, DependsOn: []string{"middle"}}
}
reasons := map[string]analyzeReconciliationReason{
"alpha": analyzeReconciliationMissing, "middle": analyzeReconciliationMissing, "zeta": analyzeReconciliationMissing,
}
plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: configured}, nil, false, analyzePlanningReconciliation(configured, reasons, nil))
if err != nil {
t.Fatal(err)
}
got := analyzePlanKeys(plan.ExecutionOrder)
if !reflect.DeepEqual(got, want) {
t.Fatalf("attempt %d order = %#v, want %#v", attempt, got, want)
}
}
}
func analyzePlanningReconciliation(
configured map[string]config.ScriptoriumArtifactConfig,
reasons map[string]analyzeReconciliationReason,
stored map[string]*manifest.AnalyzeArtifactRecord,
) analyzeReconciliation {
order, err := orderConfiguredAnalyzeArtifacts(configured)
if err != nil {
panic(err)
}
result := analyzeReconciliation{Ordered: make([]analyzeArtifactReconciliation, 0, len(order))}
for _, key := range order {
result.Ordered = append(result.Ordered, analyzeArtifactReconciliation{
Key: key, Reason: reasons[key], ExpectedFingerprint: strings.Repeat(string(key[0]), 64), Stored: stored[key],
})
}
return result
}
func analyzePlanningCurrentRecord(key string) *manifest.AnalyzeArtifactRecord {
return &manifest.AnalyzeArtifactRecord{
Key: key, Status: manifest.AnalyzeArtifactCurrent,
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
Fingerprint: strings.Repeat("a", 64),
Output: &manifest.ArtifactRecord{
Kind: "scriptorium_artifact", SourceID: artifactpolicy.ConfiguredSourceID(key),
LocalPath: "artifacts/" + key + ".md", ProducerRunID: "run-1", Checksum: strings.Repeat("b", 64),
Contract: &artifactmodel.ContractMetadata{MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1"},
},
OutputSize: 1, ProducerRunID: "run-1", UpdatedAt: time.Unix(1, 0).UTC(),
}
}
func assertAnalyzePlanKeys(t *testing.T, label string, items []analyzePlanItem, want []string) {
t.Helper()
got := analyzePlanKeys(items)
if len(got) == 0 && len(want) == 0 {
return
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("%s = %#v, want %#v", label, got, want)
}
}
func analyzePlanKeys(items []analyzePlanItem) []string {
result := make([]string, 0, len(items))
for _, item := range items {
result = append(result, item.Key)
}
return result
}