Resolve corresponding artifact family dependencies

This commit is contained in:
2026-08-30 14:20:03 +00:00
parent fb9a4d14f4
commit 257f10c9fb
8 changed files with 285 additions and 11 deletions

View File

@@ -438,7 +438,7 @@ For each `pipeline.scriptorium.artifact_families.<name>`:
| `for_each` | string | Yes | exactly `party.characters` | | `for_each` | string | Yes | exactly `party.characters` |
| `output_path_pattern` | string | Yes | safe path beneath `artifacts/` with exactly one `{character_id}` token and no other brace syntax | | `output_path_pattern` | string | Yes | safe path beneath `artifacts/` with exactly one `{character_id}` token and no other brace syntax |
| `member_vars` | map | No | maps an ordinary Scriptorium variable name to a supported canonical character selector | | `member_vars` | map | No | maps an ordinary Scriptorium variable name to a supported canonical character selector |
| `member_dependencies` | list | No | retained as typed family metadata; it does not yet add ordinary dependencies | | `member_dependencies` | list | No | unique family keys; each generated member depends on the corresponding generated member of each listed family |
| `publish` | map | No | typed family publish metadata (`enabled`, `required`, `dest_pattern`); it does not yet create publish outputs | | `publish` | map | No | typed family publish metadata (`enabled`, `required`, `dest_pattern`); it does not yet create publish outputs |
Generated keys are `<family>_<character_id>` and generated output paths must Generated keys are `<family>_<character_id>` and generated output paths must
@@ -452,6 +452,13 @@ Their resolved values are strings. A member variable may not reuse a static
`vars` name; `session_id` remains owned and overwritten by Narratio as for any `vars` name; `session_id` remains owned and overwritten by Narratio as for any
other Scriptorium artifact. other Scriptorium artifact.
Within a family only, an input source may use
`narratio.member_artifact.<family>`. The referenced family must be named in
that family's `member_dependencies`; resolution rewrites the source to the
corresponding ordinary `narratio.artifact.<family>_<character_id>` source.
This syntax is rejected in explicit artifacts and never reaches runtime stages
or Scriptorium.
### Notifications ### Notifications
Narratio currently supports only `notification.mode: noop`, which is also the Narratio currently supports only `notification.mode: noop`, which is also the

View File

@@ -102,6 +102,10 @@ Configured sources (`narratio.artifact.*`):
rewriting manifest state. Catalog construction iterates current rewriting manifest state. Catalog construction iterates current
configuration, so removed or renamed records are not advertised. configuration, so removed or renamed records are not advertised.
`narratio.member_artifact.*` is not a runtime source family. Configuration
resolution accepts it only in an artifact-family declaration and rewrites it
to the corresponding configured source before this catalog is built.
Prepared stable sources (`narratio.input.*`): Prepared stable sources (`narratio.input.*`):
- resolve only from the current manifest's exact prepared-input record; - resolve only from the current manifest's exact prepared-input record;

View File

@@ -129,5 +129,9 @@ therefore receive only concrete artifact maps.
The catalog retains sorted family member keys plus family/character/source The catalog retains sorted family member keys plus family/character/source
origins and the typed dependency/publish declarations for their later owners. origins and the typed dependency/publish declarations for their later owners.
It does not create a new runtime artifact type, dependency edge, or publish `member_dependencies` add corresponding ordinary concrete dependencies, while
rule during this resolution step. the family-only `narratio.member_artifact.<family>` input form is rewritten to
the matching ordinary configured-artifact source. The catalog records those
resolved dependency and input identities with their declaring family and party
member. No member-artifact source is registered as a runtime policy source.
The typed publish declaration still does not create a publish rule here.

View File

@@ -9,7 +9,8 @@ dependency order, and safely materialize validated outputs.
- ordinary configured artifacts from `pipeline.scriptorium.artifacts`; canonical - ordinary configured artifacts from `pipeline.scriptorium.artifacts`; canonical
party artifact families have already expanded into this map during party artifact families have already expanded into this map during
configuration resolution configuration resolution, including corresponding member dependencies and
rewritten member-artifact input sources
- optional selected artifact keys supplied through the stage environment - optional selected artifact keys supplied through the stage environment
- built-in, configured, extraction, and previous-session source references in - built-in, configured, extraction, and previous-session source references in
artifact inputs artifact inputs

View File

@@ -813,7 +813,7 @@ Scriptorium artifacts with member-specific identity, paths, and variables.
## Stage 14 — Same-Member Dependencies And Member Artifact Sources ## Stage 14 — Same-Member Dependencies And Member Artifact Sources
**Status: Pending** **Status: Completed**
### Goal ### Goal

View File

@@ -11,6 +11,7 @@ import (
const ( const (
partyCharactersFamilySource = "party.characters" partyCharactersFamilySource = "party.characters"
characterIDToken = "{character_id}" characterIDToken = "{character_id}"
memberArtifactSourcePrefix = "narratio.member_artifact."
) )
var memberVariableSelectors = map[string]func(PartyCharacter) string{ var memberVariableSelectors = map[string]func(PartyCharacter) string{
@@ -44,6 +45,9 @@ func expandPipelineArtifactFamilies(cfg *PipelineConfig, party ResolvedParty) er
} }
expanded := cloneArtifactDefinitions(cfg.Scriptorium.Artifacts) expanded := cloneArtifactDefinitions(cfg.Scriptorium.Artifacts)
if err := rejectConcreteMemberArtifactSources(expanded); err != nil {
return err
}
familyKeys := sortedFamilyKeys(families) familyKeys := sortedFamilyKeys(families)
for _, familyKey := range familyKeys { for _, familyKey := range familyKeys {
if !artifactpolicy.IsConfiguredKey(familyKey) { if !artifactpolicy.IsConfiguredKey(familyKey) {
@@ -53,6 +57,9 @@ func expandPipelineArtifactFamilies(cfg *PipelineConfig, party ResolvedParty) er
return fmt.Errorf("pipeline.scriptorium.artifact_families.%s collides with configured artifact key %q", familyKey, familyKey) return fmt.Errorf("pipeline.scriptorium.artifact_families.%s collides with configured artifact key %q", familyKey, familyKey)
} }
} }
if err := validateFamilyMemberDependencies(families, familyKeys); err != nil {
return err
}
characters := append([]PartyCharacter(nil), party.Canonical.Characters...) characters := append([]PartyCharacter(nil), party.Canonical.Characters...)
sort.Slice(characters, func(left, right int) bool { return characters[left].ID < characters[right].ID }) sort.Slice(characters, func(left, right int) bool { return characters[left].ID < characters[right].ID })
@@ -94,14 +101,22 @@ func expandPipelineArtifactFamilies(cfg *PipelineConfig, party ResolvedParty) er
if err != nil { if err != nil {
return err return err
} }
dependencies, err := expandedFamilyDependencies(prefix, family, character.ID)
if err != nil {
return err
}
inputs, err := expandFamilyInputs(prefix, family, character.ID)
if err != nil {
return err
}
expanded[key] = ScriptoriumArtifactConfig{ expanded[key] = ScriptoriumArtifactConfig{
Enabled: family.Enabled, DependsOn: append([]string(nil), family.DependsOn...), RenderDebug: family.RenderDebug, Enabled: family.Enabled, DependsOn: dependencies, RenderDebug: family.RenderDebug,
PromptID: family.PromptID, ProfileID: family.ProfileID, OutputPath: outputPath, Timeout: family.Timeout, PromptID: family.PromptID, ProfileID: family.ProfileID, OutputPath: outputPath, Timeout: family.Timeout,
Inputs: cloneArtifactInputs(family.Inputs), Vars: vars, Inputs: inputs, Vars: vars,
} }
outputOwners[outputPath] = key outputOwners[outputPath] = key
familyOrigin.Members = append(familyOrigin.Members, key) familyOrigin.Members = append(familyOrigin.Members, key)
catalog.Members[key] = ArtifactFamilyMemberOrigin{Family: familyKey, CharacterID: character.ID, Source: familyOrigin.Source} catalog.Members[key] = ArtifactFamilyMemberOrigin{Family: familyKey, CharacterID: character.ID, Source: familyOrigin.Source, Dependencies: append([]string(nil), dependencies...), Inputs: inputSourceMap(inputs)}
} }
catalog.Families[familyKey] = familyOrigin catalog.Families[familyKey] = familyOrigin
} }
@@ -109,6 +124,9 @@ func expandPipelineArtifactFamilies(cfg *PipelineConfig, party ResolvedParty) er
cfg.Scriptorium.ArtifactFamilies = nil cfg.Scriptorium.ArtifactFamilies = nil
cfg.familyCatalog = catalog cfg.familyCatalog = catalog
cfg.resolution.artifactFamiliesExpanded = true cfg.resolution.artifactFamiliesExpanded = true
if err := rejectConcreteMemberArtifactSources(expanded); err != nil {
return err
}
if err := validateScriptorium(cfg.Scriptorium, cfg.Notarius); err != nil { if err := validateScriptorium(cfg.Scriptorium, cfg.Notarius); err != nil {
return err return err
} }
@@ -118,6 +136,127 @@ func expandPipelineArtifactFamilies(cfg *PipelineConfig, party ResolvedParty) er
return nil return nil
} }
func validateFamilyMemberDependencies(families map[string]ScriptoriumArtifactFamilyConfig, familyKeys []string) error {
for _, familyKey := range familyKeys {
family := families[familyKey]
seen := make(map[string]struct{}, len(family.MemberDependencies))
for index, raw := range family.MemberDependencies {
dependency := strings.TrimSpace(raw)
prefix := fmt.Sprintf("pipeline.scriptorium.artifact_families.%s.member_dependencies[%d]", familyKey, index)
if !artifactpolicy.IsConfiguredKey(dependency) {
return fmt.Errorf("%s must be a valid family key", prefix)
}
if dependency == familyKey {
return fmt.Errorf("%s must not reference its own family", prefix)
}
if _, duplicate := seen[dependency]; duplicate {
return fmt.Errorf("%s duplicates family %q", prefix, dependency)
}
seen[dependency] = struct{}{}
dependencyFamily, exists := families[dependency]
if !exists {
return fmt.Errorf("%s references unknown family %q", prefix, dependency)
}
if strings.TrimSpace(dependencyFamily.ForEach) != partyCharactersFamilySource {
return fmt.Errorf("%s family %q must use %q", prefix, dependency, partyCharactersFamilySource)
}
}
}
return nil
}
func expandedFamilyDependencies(prefix string, family ScriptoriumArtifactFamilyConfig, characterID string) ([]string, error) {
dependencies := append([]string(nil), family.DependsOn...)
memberDependencies := append([]string(nil), family.MemberDependencies...)
sort.Strings(memberDependencies)
for _, familyKey := range memberDependencies {
dependencies = append(dependencies, familyKey+"_"+characterID)
}
return normalizedFamilyDependencies(prefix, dependencies), nil
}
func normalizedFamilyDependencies(_ string, dependencies []string) []string {
seen := make(map[string]struct{}, len(dependencies))
normalized := make([]string, 0, len(dependencies))
for _, dependency := range dependencies {
dependency = strings.TrimSpace(dependency)
if _, exists := seen[dependency]; exists {
continue
}
seen[dependency] = struct{}{}
normalized = append(normalized, dependency)
}
return normalized
}
func expandFamilyInputs(prefix string, family ScriptoriumArtifactFamilyConfig, characterID string) (map[string]ScriptoriumInputConfig, error) {
inputs := cloneArtifactInputs(family.Inputs)
for name, input := range inputs {
familyKey, matched, err := parseMemberArtifactSource(input.Source)
if err != nil {
return nil, fmt.Errorf("%s.inputs.%s.source: %w", prefix, name, err)
}
if !matched {
continue
}
if !familyDependencyContains(family.MemberDependencies, familyKey) {
return nil, fmt.Errorf("%s.inputs.%s.source %q requires member_dependencies entry %q", prefix, name, input.Source, familyKey)
}
input.Source = artifactpolicy.ConfiguredSourceID(familyKey + "_" + characterID)
inputs[name] = input
}
return inputs, nil
}
func parseMemberArtifactSource(source string) (string, bool, error) {
trimmed := strings.TrimSpace(source)
if !strings.HasPrefix(trimmed, "narratio.member_artifact") {
return "", false, nil
}
if !strings.HasPrefix(trimmed, memberArtifactSourcePrefix) {
return "", true, fmt.Errorf("malformed member artifact source %q", source)
}
family := strings.TrimPrefix(trimmed, memberArtifactSourcePrefix)
if !artifactpolicy.IsConfiguredKey(family) {
return "", true, fmt.Errorf("malformed member artifact source %q", source)
}
return family, true, nil
}
func rejectConcreteMemberArtifactSources(artifacts map[string]ScriptoriumArtifactConfig) error {
for artifactKey, artifact := range artifacts {
for inputName, input := range artifact.Inputs {
if _, matched, err := parseMemberArtifactSource(input.Source); matched {
if err != nil {
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source: %w", artifactKey, inputName, err)
}
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source uses member artifact syntax outside an artifact family", artifactKey, inputName)
}
}
}
return nil
}
func familyDependencyContains(values []string, want string) bool {
for _, value := range values {
if strings.TrimSpace(value) == want {
return true
}
}
return false
}
func inputSourceMap(inputs map[string]ScriptoriumInputConfig) map[string]string {
if len(inputs) == 0 {
return nil
}
out := make(map[string]string, len(inputs))
for name, input := range inputs {
out[name] = input.Source
}
return out
}
func validateFamilyOutputPattern(field, pattern string) error { func validateFamilyOutputPattern(field, pattern string) error {
if strings.Count(pattern, characterIDToken) != 1 { if strings.Count(pattern, characterIDToken) != 1 {
return fmt.Errorf("%s must contain exactly one %s token", field, characterIDToken) return fmt.Errorf("%s must contain exactly one %s token", field, characterIDToken)
@@ -248,6 +387,14 @@ func cloneArtifactFamilyCatalog(in ArtifactFamilyCatalog) ArtifactFamilyCatalog
out.Families[key] = family out.Families[key] = family
} }
for key, member := range in.Members { for key, member := range in.Members {
member.Dependencies = append([]string(nil), member.Dependencies...)
if len(member.Inputs) > 0 {
inputs := make(map[string]string, len(member.Inputs))
for name, source := range member.Inputs {
inputs[name] = source
}
member.Inputs = inputs
}
out.Members[key] = member out.Members[key] = member
} }
return out return out

View File

@@ -188,6 +188,115 @@ func TestArtifactFamiliesRejectGeneratedKeyAndOutputCollisions(t *testing.T) {
} }
} }
func TestArtifactFamiliesExpandSameMemberDependenciesAndInputs(t *testing.T) {
dir := t.TempDir()
writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), `schema_version: narratio.party.v1
characters:
arannis:
player: {name: Eric}
character: {name: Arannis, classes: [{name: wizard}]}
bryn:
player: {name: Bri}
character: {name: Bryn, classes: [{name: fighter}]}
`)
families := `
character_meta:
enabled: true
for_each: party.characters
prompt_id: dnd.character_meta
output_path_pattern: artifacts/characters/{character_id}/meta.md
character_items:
enabled: true
for_each: party.characters
prompt_id: dnd.character_items
output_path_pattern: artifacts/characters/{character_id}/items.md
depends_on: [session_recap]
member_dependencies: [character_meta]
inputs:
transcript: {source: narratio.transcript.final_trimmed, required: true}
prior_meta: {source: narratio.member_artifact.character_meta, required: true}
`
pipelinePath, campaignPath, sessionPath := writeArtifactFamilyConfig(t, dir, strings.Replace(artifactFamilyPipelineYAML(families), " artifact_families:", " artifacts:\n session_recap:\n enabled: false\n output_path: artifacts/session_recap.md\n artifact_families:", 1))
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err != nil {
t.Fatal(err)
}
items := cfg.Pipeline.Scriptorium.Artifacts["character_items_arannis"]
if !reflect.DeepEqual(items.DependsOn, []string{"session_recap", "character_meta_arannis"}) {
t.Fatalf("member dependencies = %#v", items.DependsOn)
}
if got := items.Inputs["prior_meta"].Source; got != "narratio.artifact.character_meta_arannis" {
t.Fatalf("rewritten member source = %q", got)
}
catalog := ArtifactFamilies(cfg.Pipeline)
if got := catalog.Members["character_items_arannis"]; !reflect.DeepEqual(got.Dependencies, items.DependsOn) || got.Inputs["prior_meta"] != "narratio.artifact.character_meta_arannis" {
t.Fatalf("member provenance = %#v", got)
}
for key, artifact := range cfg.Pipeline.Scriptorium.Artifacts {
for name, input := range artifact.Inputs {
if strings.Contains(input.Source, "narratio.member_artifact.") {
t.Fatalf("unresolved source at %s.%s = %q", key, name, input.Source)
}
}
}
}
func TestArtifactFamiliesRejectInvalidMemberDependenciesAndSources(t *testing.T) {
for _, test := range []struct{ name, families, want string }{
{name: "missing family", families: strings.Replace(validFamilyYAML, "member_vars:", "member_dependencies: [missing]\n member_vars:", 1), want: "unknown family"},
{name: "self dependency", families: strings.Replace(validFamilyYAML, "member_vars:", "member_dependencies: [character_meta]\n member_vars:", 1), want: "must not reference its own family"},
{name: "duplicate dependency", families: strings.Replace(validFamilyYAML, "member_vars:", "member_dependencies: [other, other]\n member_vars:", 1) + `
other:
for_each: party.characters
output_path_pattern: artifacts/characters/{character_id}/other.md
`, want: "duplicates family"},
{name: "undeclared source dependency", families: strings.Replace(validFamilyYAML, "member_vars:", "inputs:\n prior: {source: narratio.member_artifact.other, required: true}\n member_vars:", 1) + `
other:
for_each: party.characters
output_path_pattern: artifacts/characters/{character_id}/other.md
`, want: "requires member_dependencies"},
{name: "transitive cycle", families: strings.Replace(validFamilyYAML, "member_vars:", "member_dependencies: [other]\n member_vars:", 1) + `
other:
for_each: party.characters
output_path_pattern: artifacts/characters/{character_id}/other.md
member_dependencies: [character_meta]
`, want: "dependencies must not contain cycles"},
} {
t.Run(test.name, func(t *testing.T) {
dir := t.TempDir()
writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), canonicalPartyFixture)
pipelinePath, campaignPath, sessionPath := writeArtifactFamilyConfig(t, dir, artifactFamilyPipelineYAML(test.families))
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want %q", err, test.want)
}
})
}
pipeline := &PipelineConfig{
Scriptorium: &ScriptoriumConfig{Artifacts: map[string]ScriptoriumArtifactConfig{
"explicit": {Inputs: map[string]ScriptoriumInputConfig{
"bad": {Source: "narratio.member_artifact.character_meta"},
}},
}},
resolution: &pipelineResolutionMetadata{artifactFamilies: map[string]ScriptoriumArtifactFamilyConfig{
"character_meta": {ForEach: partyCharactersFamilySource, OutputPathPattern: "artifacts/{character_id}.md"},
}},
}
document, err := ParseParty([]byte(canonicalPartyFixture))
if err != nil {
t.Fatal(err)
}
if err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Mode: PartyModeCanonical, Canonical: document.Canonical}); err == nil || !strings.Contains(err.Error(), "outside an artifact family") {
t.Fatalf("concrete member source error = %v", err)
}
pipeline.Scriptorium.Artifacts["explicit"] = ScriptoriumArtifactConfig{Inputs: map[string]ScriptoriumInputConfig{"bad": {Source: "narratio.member_artifact."}}}
pipeline.resolution.artifactFamiliesExpanded = false
if err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Mode: PartyModeCanonical, Canonical: document.Canonical}); err == nil || !strings.Contains(err.Error(), "malformed member artifact source") {
t.Fatalf("malformed concrete member source error = %v", err)
}
}
const validFamilyYAML = ` const validFamilyYAML = `
character_meta: character_meta:
enabled: true enabled: true

View File

@@ -310,9 +310,11 @@ type ArtifactFamilyOrigin struct {
// ArtifactFamilyMemberOrigin identifies the canonical party member that // ArtifactFamilyMemberOrigin identifies the canonical party member that
// produced one ordinary concrete artifact. // produced one ordinary concrete artifact.
type ArtifactFamilyMemberOrigin struct { type ArtifactFamilyMemberOrigin struct {
Family string Family string
CharacterID string CharacterID string
Source string Source string
Dependencies []string
Inputs map[string]string
} }
// ScriptoriumInputConfig configures one named prompt input source. // ScriptoriumInputConfig configures one named prompt input source.