Compare commits
41 Commits
92e89076a2
...
ef19a03cbf
| Author | SHA1 | Date | |
|---|---|---|---|
| ef19a03cbf | |||
| 0546f6eb4f | |||
| d28d1062e0 | |||
| b3ebfcef37 | |||
| b70d9f77e3 | |||
| 2a75f40871 | |||
| a705ba74a1 | |||
| 8d9c9e7c87 | |||
| 0b5cc4f251 | |||
| 82ffe85f2d | |||
| b3644abc0e | |||
| d653bf1b90 | |||
| 3e66127b94 | |||
| 5d086c13ca | |||
| d36d4e7689 | |||
| 1456aa51cc | |||
| ffc179c822 | |||
| 8e669a1f14 | |||
| 14bfae216d | |||
| 5a58d87995 | |||
| 557809f364 | |||
| ee600975f0 | |||
| 0d8017e23f | |||
| 37b18edf3d | |||
| cda7a61b47 | |||
| 2ad9283148 | |||
| 41a8a80dda | |||
| 90c7fa6381 | |||
| e3839f8620 | |||
| 0fc2f9ee01 | |||
| 5d6305f21a | |||
| 551e4daea2 | |||
| 3589d33468 | |||
| a22c1a7f59 | |||
| ad85d71b0f | |||
| f3506240c2 | |||
| 70c199aa31 | |||
| e2b82746ab | |||
| 4235507f7b | |||
| b346670cc7 | |||
| 7868c26be7 |
@@ -22,10 +22,10 @@
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["start_segment", "end_segment"],
|
||||
"required": ["start_unit_id", "end_unit_id"],
|
||||
"properties": {
|
||||
"start_segment": {"type": "integer"},
|
||||
"end_segment": {"type": "integer"}
|
||||
"start_unit_id": {"type": "integer"},
|
||||
"end_unit_id": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
The canonical spell-name catalog for this extraction is provided below as JSON.
|
||||
Return spell names using the catalog's canonical spelling exactly. Aliases and
|
||||
other campaign reference material are not part of this catalog input and must
|
||||
not be copied into the output as spell names.
|
||||
The spell catalog for this extraction is provided below as JSON. Each entry
|
||||
lists a `canonical_name` and its recognized `aliases`. If the transcript uses
|
||||
an alias, select that entry's `canonical_name`. Return spell names using the
|
||||
canonical spelling exactly; never return an alias as a spell name.
|
||||
|
||||
{{ input "spell_catalog" }}
|
||||
|
||||
@@ -12,9 +12,9 @@ an opaque implementation detail. A plain name is likewise insufficient where
|
||||
multiple supplied records share that name.
|
||||
|
||||
The LLM boundary must preserve the typed artifact and durable-schema ownership
|
||||
of [ADR-0003](0003-strongly-typed-stage-interfaces.md) and the distinction
|
||||
of [ADR-0003](0003-typed-interfaces-with-two-zone-data-model.md) and the distinction
|
||||
between disambiguating references and source evidence in
|
||||
[ADR-0009](0009-prefer-minimal-evidence-grounded-extraction-artifacts.md).
|
||||
[ADR-0009](0009-minimal-evidence-grounded-extraction-artifacts.md).
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
@@ -67,6 +67,12 @@ including a collision with the embedded catalog. Matching uses the catalog’s
|
||||
case, whitespace, and apostrophe normalization, so authors should avoid names
|
||||
or aliases that normalize to another spell.
|
||||
|
||||
Spell extraction receives the effective catalog as deterministic canonical-name
|
||||
and alias pairs. An alias in the transcript selects its associated canonical
|
||||
name; the extractor is instructed to return that canonical spelling. The
|
||||
projection contains no catalog source metadata or provenance, and aliases
|
||||
remain recognition context rather than transcript evidence.
|
||||
|
||||
The overlay is a recognition aid only. The durable spell-artifact schema and
|
||||
source-evidence rules are defined by the
|
||||
[D&D spell artifact contract](dnd-spell-artifacts.md).
|
||||
|
||||
@@ -89,8 +89,9 @@ complete-session `common-dnd-transcript-full.md`; extraction prompts consume
|
||||
the current-chunk `common-dnd-transcript-chunk.md`; and NPC, location, and item
|
||||
normalization consume `common-dnd-transcript-windows.md` alongside their
|
||||
candidate collections. Player, party, glossary, and compatible campaign
|
||||
references provide disambiguating context, not evidence. Reference material is
|
||||
canonically ordered before rendering so equivalent inputs remain stable.
|
||||
references provide disambiguating context only when declared by the active
|
||||
prompt; they never establish evidence. Reference material is canonically
|
||||
ordered before rendering so equivalent inputs remain stable.
|
||||
|
||||
Extraction prompts render the common system and identity messages first, then
|
||||
cached campaign references and the cached chunk transcript. Evidence policy and
|
||||
@@ -116,7 +117,8 @@ result.
|
||||
|
||||
Default chains keep responsibilities separate: structural validators assess the
|
||||
candidate, source-reference validators resolve cited ranges against the current
|
||||
source, durable-schema validation checks an approved representation, and
|
||||
source and require extraction evidence to stay within the current chunk,
|
||||
durable-schema validation checks an approved representation, and
|
||||
relatedness validators report advisory evidence concerns. The configured order
|
||||
is documented in
|
||||
[Configuration](../config.md#production-validator-keys-and-default-chains).
|
||||
|
||||
@@ -102,7 +102,8 @@ because it changes scheduling rather than execution semantics.
|
||||
Production construction creates one PromptKit client and wraps it in one
|
||||
scheduled client. The scheduler has a fixed, positive permit limit, serves
|
||||
queued calls in FIFO order, and removes a queued call when its context is
|
||||
cancelled. A granted permit is released exactly once on every completion path.
|
||||
cancelled. It rechecks the caller context after admission and before dispatch.
|
||||
A granted permit is released exactly once on every completion path.
|
||||
|
||||
The scheduled wrapper surrounds every `CompleteStructured` call, so concurrent
|
||||
lanes, pipeline retries, and LLM-backed validators share the same provider-call
|
||||
@@ -179,7 +180,9 @@ structured-output validation. The adapter reports an empty result, validation
|
||||
failure, empty structured body, or decode failure as
|
||||
`ErrInvalidStructuredOutput`, while retaining the returned raw bytes and debug
|
||||
material when they exist. Provider failures remain operational errors rather
|
||||
than output-validation failures.
|
||||
than output-validation failures. Apart from documented context, capacity, and
|
||||
invalid-output categories, provider error values and types do not cross the
|
||||
adapter error chain; callers receive only a credential-redacted diagnostic.
|
||||
|
||||
When PromptKit rejects backend admission before generation, the adapter maps
|
||||
`promptkit.ErrCapacityExceeded` to
|
||||
|
||||
@@ -46,16 +46,18 @@ External reference materialization happens before preparation. The materializer
|
||||
checks that each slot is declared by the selected module, resolves a file path
|
||||
relative to the correct configuration or working-directory origin, reads
|
||||
UTF-8 text, verifies media type and size limits, and retains bounded
|
||||
provenance. A generated-artifact selector remains declared but has no bytes
|
||||
until its producing step completes.
|
||||
provenance. For a positive slot limit, it reads at most the limit plus one byte
|
||||
and rejects overflow before retaining content. A generated-artifact selector
|
||||
remains declared but has no bytes until its producing step completes.
|
||||
|
||||
Preparation is the construction boundary. It validates the resolved shape and
|
||||
registry set, clones the resolved data, then constructs the input adapter,
|
||||
chunker, stage-local validators, every typed lane, and output encoder with
|
||||
cloned options, references, and shared dependencies. It also collects stable
|
||||
checkpoint fingerprints. Missing registrations, incompatible typed entries,
|
||||
nil implementations, and constructor failures are reported before source
|
||||
parsing or any stage operation begins.
|
||||
chunker, stage-local validators, every typed lane, and output encoder. Each
|
||||
registered builder receives its own cloned build request immediately before its
|
||||
module-owned code runs. Preparation also collects stable checkpoint
|
||||
fingerprints. Missing registrations, incompatible typed entries, nil
|
||||
implementations, and constructor failures are reported before source parsing
|
||||
or any stage operation begins.
|
||||
|
||||
An output encoder can opt into source-evidence publication through its output
|
||||
policy. Preparation keeps the configured lane allowlist and active lanes
|
||||
@@ -115,9 +117,12 @@ for started workers, and prevents output encoding.
|
||||
|
||||
Every chunk, extract, merge, and normalize candidate passes its resolved
|
||||
validator chain. Validators receive immutable canonical input appropriate to
|
||||
their target: chunks, typed values, or serialized codec bytes. They may
|
||||
approve, approve with warnings, reject, or fail. A rejection is an ordinary
|
||||
pipeline result; a validator error is a framework error.
|
||||
their target: chunks, codec-decoded typed candidates, or serialized codec
|
||||
bytes. Each typed validator receives a newly decoded value from the one
|
||||
candidate serialization for that attempt, while serialized validators receive
|
||||
separately owned representation bytes and schema metadata. They may approve,
|
||||
approve with warnings, reject, or fail. A rejection is an ordinary pipeline
|
||||
result; a validator error is a framework error.
|
||||
|
||||
The runner applies the binding's retry policy around a stage operation and its
|
||||
complete validation chain. It preserves warnings only from the final accepted
|
||||
|
||||
@@ -47,6 +47,9 @@ The serialized
|
||||
they do not describe a current public state surface.
|
||||
|
||||
Ordered-step lane checkpoints include the step identity in their storage scope.
|
||||
Accepted step and lane identities are encoded injectively before becoming
|
||||
filesystem path components, while ordinary safe identifiers retain their
|
||||
readable paths.
|
||||
When a later lane consumes a generated artifact, its dependency fingerprints
|
||||
include the producer's artifact kind, complete schema identity, media type,
|
||||
canonical content digest, and size. Ordinary resume compares those fingerprints
|
||||
|
||||
2474
docs/roadmap/audit.md
Normal file
2474
docs/roadmap/audit.md
Normal file
File diff suppressed because it is too large
Load Diff
81
internal/cli/assembled_enemy_event_codec_contract_test.go
Normal file
81
internal/cli/assembled_enemy_event_codec_contract_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
|
||||
const invalidEnemyEventExtractorKey = "test/dnd/invalid-enemy-events"
|
||||
|
||||
func TestAssembledEnemyEventLaneRejectsInvalidFinalArtifactDespiteValidatorOverrides(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
if err := pipeline.RegisterExtractor[dnd.EnemyEventList](components.registries.Extractors, pipeline.ModuleSpec{
|
||||
Key: invalidEnemyEventExtractorKey,
|
||||
Stage: pipeline.StageExtract,
|
||||
ExecutionClass: contracts.ExecutionClassDeterministic,
|
||||
Requires: []string{"chunks", "source.transcript"},
|
||||
Provides: []string{"dnd.enemy_events"},
|
||||
ArtifactKind: dnd.EnemyEventListKind,
|
||||
}, func() (contracts.Extractor[dnd.EnemyEventList], error) {
|
||||
return invalidEnemyEventExtractor{}, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("register extractor: %v", err)
|
||||
}
|
||||
|
||||
accept := pipeline.ValidatorOverride{Set: true, Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}}
|
||||
resolved, err := pipeline.ResolvePipeline(pipeline.PipelineProfile{
|
||||
ID: "assembled-invalid-enemy-events",
|
||||
Input: pipeline.Binding("seriatim"),
|
||||
Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}},
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"enemy-events": {
|
||||
Extract: pipeline.ModuleBinding{Module: invalidEnemyEventExtractorKey, Validators: accept},
|
||||
Normalize: pipeline.ModuleBinding{Module: pipeline.DefaultNormalizeModule, Validators: accept},
|
||||
},
|
||||
},
|
||||
Output: pipeline.Binding("json"),
|
||||
}, pipeline.ResolveOptions{}, catalogFromRegistries(components.registries))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePipeline() error = %v", err)
|
||||
}
|
||||
|
||||
prepared, err := pipeline.Prepare(resolved, components.registries, pipeline.ModuleDependencies{})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare() error = %v", err)
|
||||
}
|
||||
_, err = pipeline.New().Run(context.Background(), pipeline.RunInput{
|
||||
Prepared: prepared,
|
||||
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
|
||||
ChunkCacheMode: pipeline.ChunkCacheBypass,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "serialize accepted extract output") || !strings.Contains(err.Error(), "must not exceed") {
|
||||
t.Fatalf("Run() error = %v, want final durable range rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
type invalidEnemyEventExtractor struct{}
|
||||
|
||||
func (invalidEnemyEventExtractor) Key() string { return invalidEnemyEventExtractorKey }
|
||||
|
||||
func (invalidEnemyEventExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
|
||||
func (invalidEnemyEventExtractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.EnemyEventList], error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, err
|
||||
}
|
||||
if req.Source == nil {
|
||||
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, errors.New("assembled extractor requires source")
|
||||
}
|
||||
return contracts.TypedExtractionResult[dnd.EnemyEventList]{Value: dnd.EnemyEventList{Events: []dnd.EnemyEvent{{
|
||||
Name: "Ashfang",
|
||||
Kind: dnd.EnemyEventKindEngaged,
|
||||
SourceRefs: []source.SourceRef{{SourceID: req.Source.ID, StartUnitID: 2, EndUnitID: 1}},
|
||||
}}}}, nil
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func TestAssembledSpellPipelineHonorsNormalizeValidatorOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembledSpellPipelineRejectsUnknownSpellWithoutPromotingAttemptWarning(t *testing.T) {
|
||||
func TestAssembledSpellPipelinePromotesTerminalUnknownSpellWarning(t *testing.T) {
|
||||
registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{unknownSpell: true})
|
||||
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
|
||||
if err != nil {
|
||||
@@ -161,10 +161,8 @@ func TestAssembledSpellPipelineRejectsUnknownSpellWithoutPromotingAttemptWarning
|
||||
if !reflect.DeepEqual(rejectedFile.Rejected, output.Rejected) {
|
||||
t.Fatalf("rejected file = %#v, run rejections = %#v, want durable rejection diagnostic", rejectedFile.Rejected, output.Rejected)
|
||||
}
|
||||
for _, warning := range output.Warnings {
|
||||
if warning.ReasonCode == spellnormalize.ReasonCodeSpellNameUnresolved {
|
||||
t.Fatalf("warnings = %#v, want rejected-attempt warning to remain non-durable", output.Warnings)
|
||||
}
|
||||
if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != spellnormalize.ReasonCodeSpellNameUnresolved || output.Warnings[0].Scope != "spell_casts[0]" {
|
||||
t.Fatalf("warnings = %#v, want terminal normalize catalog warning", output.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
|
||||
if len(registry.Items) != 1 || registry.Items[0].Name != "Moonblade" {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated item registry has %d items, want 1", len(registry.Items))
|
||||
}
|
||||
content = []byte(`{"occurrences":[{"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":5,"end_segment":5}]}]}`)
|
||||
content = []byte(`{"occurrences":[{"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_unit_id":5,"end_unit_id":5}]}]}`)
|
||||
}
|
||||
case combat.PromptID:
|
||||
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`)
|
||||
|
||||
@@ -400,6 +400,9 @@ func (referenceContractCodecA) Encode(stateTestArtifact) ([]byte, error) {
|
||||
func (referenceContractCodecA) Decode([]byte) (stateTestArtifact, error) {
|
||||
return stateTestArtifact{Value: "ok"}, nil
|
||||
}
|
||||
func (codec referenceContractCodecA) DecodeCandidate(content []byte) (stateTestArtifact, error) {
|
||||
return codec.Decode(content)
|
||||
}
|
||||
|
||||
func (referenceContractCodecB) Kind() contracts.ArtifactKind { return referenceContractKindBeta }
|
||||
func (referenceContractCodecB) Schema() contracts.ArtifactSchema {
|
||||
@@ -415,6 +418,9 @@ func (referenceContractCodecB) Encode(stateTestArtifact) ([]byte, error) {
|
||||
func (referenceContractCodecB) Decode([]byte) (stateTestArtifact, error) {
|
||||
return stateTestArtifact{Value: "ok"}, nil
|
||||
}
|
||||
func (codec referenceContractCodecB) DecodeCandidate(content []byte) (stateTestArtifact, error) {
|
||||
return codec.Decode(content)
|
||||
}
|
||||
|
||||
func referenceContractLane(t *testing.T, resolved pipeline.ResolvedPipeline, id string) pipeline.ResolvedArtifactLane {
|
||||
t.Helper()
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/checkpoint"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkplan"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
@@ -147,7 +148,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
||||
machineOutput := fs.Bool("json", false, "write the successful run result as JSON")
|
||||
debug := fs.Bool("debug", false, "write a debug bundle")
|
||||
debugDir := fs.String("debug-dir", "", "debug bundle directory")
|
||||
llmProfile := fs.String("llm-profile", "", "LLM profile override")
|
||||
llmProfile := singleValueFlag{name: "--llm-profile"}
|
||||
reasoningEffort := singleValueFlag{name: "--reasoning-effort"}
|
||||
clearReasoningEffort := fs.Bool("clear-reasoning-effort", false, "clear the LLM profile reasoning effort")
|
||||
resume := fs.Bool("resume", false, "reuse compatible recorded checkpoints")
|
||||
@@ -157,6 +158,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
||||
referenceFlags := stringListFlag{}
|
||||
withoutReferenceFlags := stringListFlag{}
|
||||
fs.Var(&requestedSessionID, "session-id", "prompt session identifier")
|
||||
fs.Var(&llmProfile, "llm-profile", "LLM profile override")
|
||||
fs.Var(&reasoningEffort, "reasoning-effort", "reasoning effort override")
|
||||
fs.Var(&chunkCache, "chunk_cache", "chunk plan cache mode: auto, bypass, or refresh")
|
||||
fs.Var(&referenceFlags, "reference", "reference binding, as slot=path, chunk.slot=path, merge.slot=path, lane.slot=path, lane.extract.slot=path, lane.merge.slot=path, or lane.normalize.slot=path")
|
||||
@@ -203,6 +205,10 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
||||
fmt.Fprintln(stderr, "notarius: --session-id must not be empty")
|
||||
return 2
|
||||
}
|
||||
if llmProfile.set && strings.TrimSpace(llmProfile.value) == "" {
|
||||
fmt.Fprintln(stderr, "notarius: --llm-profile must not be empty")
|
||||
return 2
|
||||
}
|
||||
if reasoningEffort.set && *clearReasoningEffort {
|
||||
fmt.Fprintln(stderr, "notarius: --reasoning-effort cannot be combined with --clear-reasoning-effort")
|
||||
return 2
|
||||
@@ -338,7 +344,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
||||
PipelineID: pipelineID,
|
||||
Only: only,
|
||||
Catalog: catalog,
|
||||
LLMProfileOverride: *llmProfile,
|
||||
LLMProfileOverride: strings.TrimSpace(llmProfile.value),
|
||||
ReferenceOverrides: referenceOverrides,
|
||||
ReferenceUnbinds: referenceUnbinds,
|
||||
})
|
||||
@@ -426,7 +432,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
||||
if err != nil {
|
||||
return failPipelineCommand(stderr, commandState, terminalWriter, err)
|
||||
}
|
||||
checkpointRecorder, checkpointLoader, err := checkpointHandlersForRun(effective.Config.Cache.Checkpoints, opts, effective.ResolvedPipeline, prepared.CheckpointFingerprints(), llmFingerprints, rawInput, only, llmProfiles, strings.TrimSpace(*llmProfile), effectiveSessionID, runtimeOverrides, *resume)
|
||||
checkpointRecorder, checkpointLoader, err := checkpointHandlersForRun(effective.Config.Cache.Checkpoints, opts, effective.ResolvedPipeline, prepared.CheckpointFingerprints(), llmFingerprints, rawInput, only, llmProfiles, strings.TrimSpace(llmProfile.value), effectiveSessionID, runtimeOverrides, *resume)
|
||||
if err != nil {
|
||||
return failPipelineCommand(stderr, commandState, terminalWriter, err)
|
||||
}
|
||||
@@ -752,17 +758,10 @@ func configSource(configPath string) string {
|
||||
}
|
||||
|
||||
func writeOutputFiles(runOutputDir string, files []contracts.OutputFile) error {
|
||||
type outputTarget struct {
|
||||
path string
|
||||
file contracts.OutputFile
|
||||
}
|
||||
targets := make([]outputTarget, 0, len(files))
|
||||
for _, file := range files {
|
||||
targetPath, err := outputFilePath(runOutputDir, file.Name)
|
||||
if err != nil {
|
||||
if _, err := outputFilePath(runOutputDir, file.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
targets = append(targets, outputTarget{path: targetPath, file: file})
|
||||
}
|
||||
|
||||
outputParent := filepath.Dir(runOutputDir)
|
||||
@@ -775,12 +774,9 @@ func writeOutputFiles(runOutputDir string, files []contracts.OutputFile) error {
|
||||
}
|
||||
return fmt.Errorf("create output run directory %q: %w", runOutputDir, err)
|
||||
}
|
||||
for _, target := range targets {
|
||||
if err := os.MkdirAll(filepath.Dir(target.path), 0o755); err != nil {
|
||||
return fmt.Errorf("create output directory %q: %w", filepath.Dir(target.path), err)
|
||||
}
|
||||
if err := writeFileAtomic(target.path, target.file.Bytes, 0o644); err != nil {
|
||||
return fmt.Errorf("write output file %q: %w", target.file.Name, err)
|
||||
for _, file := range files {
|
||||
if err := fileio.WriteBytes(runOutputDir, file.Name, file.Bytes, 0o755, 0o644); err != nil {
|
||||
return fmt.Errorf("write output file %q: %w", file.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -823,38 +819,6 @@ func outputFilePath(runOutputDir, logicalName string) (string, error) {
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
temp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
removeTemp := true
|
||||
defer func() {
|
||||
if removeTemp {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Chmod(perm); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempPath, path); err != nil {
|
||||
return err
|
||||
}
|
||||
removeTemp = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func reorderRunArgs(args []string) []string {
|
||||
var flags []string
|
||||
var positionals []string
|
||||
|
||||
@@ -43,6 +43,12 @@ func TestRunControlsRejectSyntaxWithoutAllocatingState(t *testing.T) {
|
||||
{name: "blank session ID", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--session-id", ""}
|
||||
}},
|
||||
{name: "blank LLM profile", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--llm-profile", ""}
|
||||
}},
|
||||
{name: "whitespace LLM profile", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--llm-profile", " \t "}
|
||||
}},
|
||||
{name: "multiple pipeline IDs", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "extra", "--config", roots.config, "--input", roots.input}
|
||||
}},
|
||||
@@ -253,7 +259,7 @@ func TestRunLLMProfileOverrideAndValidationUseInjectedBoundaries(t *testing.T) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", "override-profile"}, &stdout, &stderr, opts)
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", " override-profile "}, &stdout, &stderr, opts)
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
@@ -38,10 +38,29 @@ func TestWriteOutputFilesSupportsNestedLogicalPaths(t *testing.T) {
|
||||
if err := writeOutputFiles(runPath, []contracts.OutputFile{{Name: "nested/result.json", Bytes: []byte("result")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(runPath, "nested", "result.json"))
|
||||
resultPath := filepath.Join(runPath, "nested", "result.json")
|
||||
data, err := os.ReadFile(resultPath)
|
||||
if err != nil || string(data) != "result" {
|
||||
t.Fatalf("nested output = %q, %v", data, err)
|
||||
}
|
||||
for path, want := range map[string]os.FileMode{runPath: 0o755, filepath.Join(runPath, "nested"): 0o755, resultPath: 0o644} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != want {
|
||||
t.Fatalf("%s mode = %#o, want %#o", path, info.Mode().Perm(), want)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(runPath, "nested"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry.Name(), ".tmp-") {
|
||||
t.Fatalf("temporary file remains: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteOutputFilesRejectsUnsafeNamesBeforeAllocatingRunDirectory(t *testing.T) {
|
||||
@@ -75,7 +94,7 @@ func TestWriteOutputFilesRetainsNewPartialDirectoryAndPreservesSibling(t *testin
|
||||
{Name: "blocked", Bytes: []byte("partial output")},
|
||||
{Name: "blocked/nested.json", Bytes: []byte("unreachable")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "create output directory") {
|
||||
if err == nil || !strings.Contains(err.Error(), `write output file "blocked/nested.json"`) {
|
||||
t.Fatalf("writeOutputFiles() error = %v, want later directory failure", err)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(runPath, "blocked")); err != nil || string(got) != "partial output" {
|
||||
|
||||
@@ -410,8 +410,8 @@ func TestMaintainedProductionOverlayRunAlignsGroundingValidationAndProvenance(t
|
||||
t.Fatalf("spell requests = %d, want one", len(requests))
|
||||
}
|
||||
catalogInput, ok := requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot]
|
||||
if !ok || !strings.Contains(string(catalogInput.Content), "Aegis of Emberfall") || strings.Contains(string(catalogInput.Content), "Emberfall Aegis") {
|
||||
t.Fatalf("spell catalog prompt input = %#v, want canonical overlay name without alias", catalogInput)
|
||||
if !ok || !strings.Contains(string(catalogInput.Content), `"canonical_name":"Aegis of Emberfall"`) || !strings.Contains(string(catalogInput.Content), `"aliases":["Emberfall Aegis"]`) {
|
||||
t.Fatalf("spell catalog prompt input = %#v, want canonical overlay name and recognition alias", catalogInput)
|
||||
}
|
||||
artifact := readProductionJSON[dnd.SpellList](t, filepath.Join(runRoot, "lanes", "spells.json"))
|
||||
if len(artifact.SpellCasts) != 1 || artifact.SpellCasts[0].Spell != "Aegis of Emberfall" {
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestProductionSpellCatalogValidationRetries(t *testing.T) {
|
||||
t.Fatalf("rejection = %#v, want exhausted unknown-spell rejection", rejection)
|
||||
}
|
||||
if len(output.Warnings) != 0 {
|
||||
t.Fatalf("warnings = %#v, want no warnings from rejected attempts", output.Warnings)
|
||||
t.Fatalf("warnings = %#v, want no emitted warnings from rejected attempts", output.Warnings)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -955,6 +955,9 @@ func (stateTestCodec) Encode(v stateTestArtifact) ([]byte, error) {
|
||||
func (stateTestCodec) Decode([]byte) (stateTestArtifact, error) {
|
||||
return stateTestArtifact{Value: "ok"}, nil
|
||||
}
|
||||
func (codec stateTestCodec) DecodeCandidate(content []byte) (stateTestArtifact, error) {
|
||||
return codec.Decode(content)
|
||||
}
|
||||
|
||||
type stateTestExtractor struct{ harness *stateTestHarness }
|
||||
|
||||
|
||||
@@ -427,6 +427,10 @@ func (effectiveCodec) Decode(content []byte) (effectiveArtifact, error) {
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (codec effectiveCodec) DecodeCandidate(content []byte) (effectiveArtifact, error) {
|
||||
return codec.Decode(content)
|
||||
}
|
||||
|
||||
type effectiveInput struct{ key string }
|
||||
|
||||
func (m effectiveInput) Key() string { return m.key }
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -349,6 +350,12 @@ func ParseFileConfigYAML(data []byte) (FileConfig, error) {
|
||||
if err := decoder.Decode(&fileCfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("decode yaml: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err == nil {
|
||||
return FileConfig{}, fmt.Errorf("config must contain exactly one YAML document")
|
||||
} else if err != io.EOF {
|
||||
return FileConfig{}, fmt.Errorf("decode trailing yaml document: %w", err)
|
||||
}
|
||||
return fileCfg, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,28 @@ func TestFileConfigMinimalVersion4AppliesOverDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigYAMLRejectsAdditionalDocuments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "trailing whitespace", source: "version: 4\n\n \t", wantErr: false},
|
||||
{name: "trailing comment", source: "version: 4\n# trailing comment\n", wantErr: false},
|
||||
{name: "second valid document", source: "version: 4\n---\nversion: 4\n", wantErr: true},
|
||||
{name: "second empty document", source: "version: 4\n---\n", wantErr: true},
|
||||
{name: "second malformed document", source: "version: 4\n---\nversion: [\n", wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ParseFileConfigYAML([]byte(tt.source))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("ParseFileConfigYAML() error = %v, want error=%t", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilePipelineLLMProfileIsPresenceAwareAndDetached(t *testing.T) {
|
||||
const pipelineYAML = `version: 4
|
||||
pipelines:
|
||||
|
||||
@@ -10,6 +10,38 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EncodePathComponent returns a filesystem-safe, injective representation of
|
||||
// one logical path component.
|
||||
func EncodePathComponent(value string) string {
|
||||
if value == "" {
|
||||
return "%"
|
||||
}
|
||||
|
||||
const hexadecimal = "0123456789ABCDEF"
|
||||
var out strings.Builder
|
||||
for index := 0; index < len(value); index++ {
|
||||
byteValue := value[index]
|
||||
switch {
|
||||
case byteValue >= 'a' && byteValue <= 'z', byteValue >= 'A' && byteValue <= 'Z', byteValue >= '0' && byteValue <= '9', byteValue == '-', byteValue == '_':
|
||||
out.WriteByte(byteValue)
|
||||
case byteValue == '.' && safePathDot(value, index):
|
||||
out.WriteByte(byteValue)
|
||||
default:
|
||||
out.WriteByte('%')
|
||||
out.WriteByte(hexadecimal[byteValue>>4])
|
||||
out.WriteByte(hexadecimal[byteValue&0x0f])
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func safePathDot(value string, index int) bool {
|
||||
if value == "." || value == ".." {
|
||||
return false
|
||||
}
|
||||
return (index == 0 || value[index-1] != '.') && (index+1 == len(value) || value[index+1] != '.')
|
||||
}
|
||||
|
||||
func SafePath(root, name string) (string, error) {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
|
||||
@@ -15,6 +15,39 @@ func TestSafePathRejectsUnsafeNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodePathComponentIsInjectiveAndSafe(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seen := make(map[string]string)
|
||||
for _, test := range []struct {
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{value: "", want: "%"},
|
||||
{value: ".", want: "%2E"},
|
||||
{value: "..", want: "%2E%2E"},
|
||||
{value: "_", want: "_"},
|
||||
{value: "a..b", want: "a%2E%2Eb"},
|
||||
{value: "safe.identifier-9", want: "safe.identifier-9"},
|
||||
{value: "left/right", want: "left%2Fright"},
|
||||
{value: "%", want: "%25"},
|
||||
{value: "~", want: "%7E"},
|
||||
{value: " a ", want: "%20a%20"},
|
||||
{value: "é", want: "%C3%A9"},
|
||||
} {
|
||||
got := EncodePathComponent(test.value)
|
||||
if got != test.want {
|
||||
t.Errorf("EncodePathComponent(%q) = %q, want %q", test.value, got, test.want)
|
||||
}
|
||||
if previous, ok := seen[got]; ok {
|
||||
t.Errorf("EncodePathComponent(%q) = %q, collides with %q", test.value, got, previous)
|
||||
}
|
||||
seen[got] = test.value
|
||||
if _, err := SafePath(root, "components/"+got); err != nil {
|
||||
t.Errorf("EncodePathComponent(%q) produced unsafe component %q: %v", test.value, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteBytesIsAtomicAndUsesRequestedModes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := WriteBytes(root, "nested/value", []byte("value"), 0o700, 0o600); err != nil {
|
||||
|
||||
@@ -475,36 +475,9 @@ func laneManifestPath(stage string, stepID string, laneID string) string {
|
||||
|
||||
func lanePayloadPath(stage string, stepID string, laneID string, file string) string {
|
||||
if strings.TrimSpace(stepID) == "" {
|
||||
return path.Join(stage, checkpointPathComponent(laneID), file)
|
||||
return path.Join(stage, fileio.EncodePathComponent(laneID), file)
|
||||
}
|
||||
return path.Join(stage, checkpointPathComponent(stepID), checkpointPathComponent(laneID), file)
|
||||
}
|
||||
|
||||
func checkpointPathComponent(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "_"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '-' || r == '_' || r == '.':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteString(fmt.Sprintf("~%x", r))
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if out == "." || out == ".." || strings.Contains(out, "..") {
|
||||
return "_"
|
||||
}
|
||||
return out
|
||||
return path.Join(stage, fileio.EncodePathComponent(stepID), fileio.EncodePathComponent(laneID), file)
|
||||
}
|
||||
|
||||
func contentDigest(content []byte) string {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
@@ -66,6 +68,56 @@ func TestStepAwareRecorderAndLoaderIsolateLaneState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepAwareCheckpointPreservesDistinctDotIdentities(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
identity := testIdentity(t)
|
||||
recorder, err := NewFilesystemRecorder(root, identity)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stepRecorder := recorder.(pipeline.StepCheckpointRecorder)
|
||||
for _, test := range []struct {
|
||||
stepID string
|
||||
content string
|
||||
}{
|
||||
{stepID: ".", content: `{"identity":"dot"}`},
|
||||
{stepID: "..", content: `{"identity":"dot-dot"}`},
|
||||
} {
|
||||
artifact := pipeline.CheckpointArtifact{
|
||||
LaneID: "lane", ModuleKey: "normalize-module", SourceID: "source", ChunkID: "chunk", ChunkRef: source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}, SchemaDigest: "sha256:schema",
|
||||
Artifact: contracts.SerializedArtifact{Kind: "kind", Schema: contracts.ArtifactSchema{ID: "schema", Name: "Schema", Version: "1"}, MediaType: "application/json", Content: []byte(test.content)},
|
||||
}
|
||||
if err := stepRecorder.NormalizeSucceededForStep(test.stepID, "lane", "normalize-module", nil, artifact, nil); err != nil {
|
||||
t.Fatalf("record %q: %v", test.stepID, err)
|
||||
}
|
||||
}
|
||||
|
||||
loader, err := NewFilesystemLoader(root, identity)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
stepID string
|
||||
content string
|
||||
path string
|
||||
}{
|
||||
{stepID: ".", content: `{"identity":"dot"}`, path: "%2E"},
|
||||
{stepID: "..", content: `{"identity":"dot-dot"}`, path: "%2E%2E"},
|
||||
} {
|
||||
loaded, decision := loader.AcceptedNormalize(test.stepID, "lane", "normalize-module")
|
||||
if !decision.Reused || string(loaded.Output.Artifact.Content) != test.content {
|
||||
t.Errorf("load %q = %#v, decision=%#v", test.stepID, loaded, decision)
|
||||
}
|
||||
relative, err := identity.RelativePath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, relative, "normalize", test.path, "lane", "manifest.json")); err != nil {
|
||||
t.Errorf("checkpoint for %q: %v", test.stepID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointSchemaCompatibilityIdentifiers(t *testing.T) {
|
||||
if WorkspaceSchemaVersion != "notarius.workspace.v3" || WorkspaceSchemaVersionV2 != "notarius.workspace.v2" || WorkspaceSchemaVersionV1 != "notarius.workspace.v1" {
|
||||
t.Fatal("checkpoint schema identifiers are incorrect")
|
||||
|
||||
@@ -102,11 +102,11 @@ func Build(request BuildRequest) (ChunkMap, error) {
|
||||
Annotations: source.CloneChunkAnnotations(chunk.Annotations),
|
||||
}
|
||||
}
|
||||
canonical, err := canonicalize(value)
|
||||
canonical, err := canonicalizeOwned(value)
|
||||
if err != nil {
|
||||
return ChunkMap{}, fmt.Errorf("validate chunk map: %w", err)
|
||||
}
|
||||
return clone(canonical), nil
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
// Serialize builds and encodes the framework-owned serialized artifact.
|
||||
@@ -132,7 +132,7 @@ func (c *Codec) Encode(value ChunkMap) ([]byte, error) {
|
||||
if _, err := c.schemaBytes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonical, err := canonicalize(clone(value))
|
||||
canonical, err := canonicalize(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode source chunk map: %w", err)
|
||||
}
|
||||
@@ -163,11 +163,11 @@ func (c *Codec) Decode(content []byte) (ChunkMap, error) {
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
return ChunkMap{}, fmt.Errorf("decode source chunk map: multiple JSON values")
|
||||
}
|
||||
canonical, err := canonicalize(value)
|
||||
canonical, err := canonicalizeOwned(value)
|
||||
if err != nil {
|
||||
return ChunkMap{}, fmt.Errorf("decode source chunk map: %w", err)
|
||||
}
|
||||
return clone(canonical), nil
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func (c *Codec) schemaBytes() ([]byte, error) {
|
||||
@@ -241,6 +241,10 @@ func hasRequiredFields(required []string) bool {
|
||||
}
|
||||
|
||||
func canonicalize(value ChunkMap) (ChunkMap, error) {
|
||||
return canonicalizeOwned(clone(value))
|
||||
}
|
||||
|
||||
func canonicalizeOwned(value ChunkMap) (ChunkMap, error) {
|
||||
if err := requireIdentity("source_id", value.SourceID); err != nil {
|
||||
return ChunkMap{}, err
|
||||
}
|
||||
|
||||
@@ -56,6 +56,15 @@ func TestCodecRoundTripsValidFixture(t *testing.T) {
|
||||
if !bytes.Equal(encoded, bytes.TrimSpace(fixture)) {
|
||||
t.Fatalf("fixture does not use canonical encoding\nwant: %s\n got: %s", fixture, encoded)
|
||||
}
|
||||
value.PlanAnnotations["test/chunker"][0] = '['
|
||||
value.Chunks[0].Annotations["test/chunker"][0] = '['
|
||||
decoded, err := codec.Decode(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(encoded) after mutation error = %v", err)
|
||||
}
|
||||
if string(decoded.PlanAnnotations["test/chunker"]) != `{"label":"fixture"}` || string(decoded.Chunks[0].Annotations["test/chunker"]) != `{"category":"sample"}` {
|
||||
t.Fatalf("Decode() reused mutable chunk-map storage: %#v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCanonicalizesAnnotationFormatting(t *testing.T) {
|
||||
|
||||
@@ -50,6 +50,13 @@ type ArtifactCodec[T any] interface {
|
||||
Decode([]byte) (T, error)
|
||||
}
|
||||
|
||||
// CandidateArtifactCodec extends an artifact codec with strict representation
|
||||
// decoding for values that have not yet passed semantic validation.
|
||||
type CandidateArtifactCodec[T any] interface {
|
||||
ArtifactCodec[T]
|
||||
DecodeCandidate([]byte) (T, error)
|
||||
}
|
||||
|
||||
// DigestArtifactSchema returns the SHA-256 digest of the exact JSON Schema
|
||||
// bytes. Schema formatting is therefore part of the registered identity.
|
||||
func DigestArtifactSchema(schema ArtifactSchema) string {
|
||||
|
||||
@@ -84,11 +84,11 @@ func Build(request BuildRequest) (Document, error) {
|
||||
}
|
||||
document.Contexts = append(document.Contexts, context)
|
||||
}
|
||||
canonical, err := canonicalize(document)
|
||||
canonical, err := canonicalizeOwned(document)
|
||||
if err != nil {
|
||||
return Document{}, fmt.Errorf("validate evidence context: %w", err)
|
||||
}
|
||||
return clone(canonical)
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
type evidenceKey struct {
|
||||
|
||||
@@ -93,11 +93,11 @@ func (c *Codec) Decode(content []byte) (Document, error) {
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
return Document{}, fmt.Errorf("decode evidence context: multiple JSON values")
|
||||
}
|
||||
canonical, err := canonicalize(value)
|
||||
canonical, err := canonicalizeOwned(value)
|
||||
if err != nil {
|
||||
return Document{}, fmt.Errorf("decode evidence context: %w", err)
|
||||
}
|
||||
return clone(canonical)
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func (c *Codec) schemaBytes() ([]byte, error) {
|
||||
@@ -171,7 +171,10 @@ func canonicalize(value Document) (Document, error) {
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
value = owned
|
||||
return canonicalizeOwned(owned)
|
||||
}
|
||||
|
||||
func canonicalizeOwned(value Document) (Document, error) {
|
||||
if err := requireIdentity("source_id", value.SourceID); err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
@@ -230,10 +233,7 @@ func canonicalizeContext(sourceID string, selected map[string]struct{}, seenUnit
|
||||
}
|
||||
positions := make(map[int]int, len(value.Units))
|
||||
for unitIndex := range value.Units {
|
||||
unit, err := cloneSourceUnit(value.Units[unitIndex])
|
||||
if err != nil {
|
||||
return Context{}, fmt.Errorf("%s.units[%d]: %w", prefix, unitIndex, err)
|
||||
}
|
||||
unit := value.Units[unitIndex]
|
||||
if unit.ID <= 0 || strings.TrimSpace(unit.Kind) == "" || strings.TrimSpace(unit.Text) == "" {
|
||||
return Context{}, fmt.Errorf("%s.units[%d] has invalid required fields", prefix, unitIndex)
|
||||
}
|
||||
@@ -251,7 +251,6 @@ func canonicalizeContext(sourceID string, selected map[string]struct{}, seenUnit
|
||||
}
|
||||
positions[unit.ID] = unitIndex
|
||||
seenUnits[unit.ID] = struct{}{}
|
||||
value.Units[unitIndex] = unit
|
||||
}
|
||||
if value.ContextRef.StartUnitID != value.Units[0].ID || value.ContextRef.EndUnitID != value.Units[len(value.Units)-1].ID {
|
||||
return Context{}, fmt.Errorf("%s.context_ref must identify the first and last units", prefix)
|
||||
|
||||
@@ -180,12 +180,12 @@ func TestCodecRoundTripsCompactFixtureAndOwnsDecodedValues(t *testing.T) {
|
||||
t.Fatalf("fixture does not use canonical encoding\nwant: %s\n got: %s", fixture, encoded)
|
||||
}
|
||||
value.Contexts[0].Units[0].Text = "changed"
|
||||
decoded, err := codec.Decode(fixture)
|
||||
decoded, err := codec.Decode(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Contexts[0].Units[0].Text != "The party meets Rowan." {
|
||||
t.Fatal("Decode() reused mutable document storage")
|
||||
t.Fatal("Encode() retained mutable document storage")
|
||||
}
|
||||
built, err := Build(BuildRequest{Source: testDocument(t), WindowUnits: 1, SelectedLanes: []string{"npcs"}, LaneEvidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}}})
|
||||
if err != nil {
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/readonlyfs"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
)
|
||||
|
||||
@@ -223,7 +221,7 @@ func newAssetSource(fsys fs.FS, root string) (AssetSource, error) {
|
||||
}
|
||||
|
||||
func flattenAssetSources(sources []AssetSource) (fs.FS, error) {
|
||||
out := assetMapFS{}
|
||||
out := make(map[string][]byte)
|
||||
for _, source := range sources {
|
||||
if err := fs.WalkDir(source.FS, source.Root, func(name string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
@@ -247,13 +245,13 @@ func flattenAssetSources(sources []AssetSource) (fs.FS, error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out[rel] = append([]byte(nil), data...)
|
||||
out[rel] = data
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("walk asset root %s: %w", source.Root, err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return readonlyfs.New(out)
|
||||
}
|
||||
|
||||
func cleanAssetRoot(root string) (string, error) {
|
||||
@@ -275,156 +273,3 @@ func cleanAssetPath(name string) (string, error) {
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
type assetMapFS map[string][]byte
|
||||
|
||||
func (m assetMapFS) Open(name string) (fs.File, error) {
|
||||
cleaned, err := cleanOpenPath(name)
|
||||
if err != nil {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
|
||||
}
|
||||
if data, ok := m[cleaned]; ok {
|
||||
return &assetFile{
|
||||
reader: bytes.NewReader(data),
|
||||
info: assetFileInfo{name: path.Base(cleaned), size: int64(len(data))},
|
||||
}, nil
|
||||
}
|
||||
entries := m.dirEntries(cleaned)
|
||||
if entries != nil {
|
||||
return &assetDir{name: path.Base(cleaned), entries: entries}, nil
|
||||
}
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
|
||||
func (m assetMapFS) ReadFile(name string) ([]byte, error) {
|
||||
cleaned, err := cleanOpenPath(name)
|
||||
if err != nil {
|
||||
return nil, &fs.PathError{Op: "readfile", Path: name, Err: err}
|
||||
}
|
||||
data, ok := m[cleaned]
|
||||
if !ok {
|
||||
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
|
||||
func (m assetMapFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
cleaned, err := cleanOpenPath(name)
|
||||
if err != nil {
|
||||
return nil, &fs.PathError{Op: "readdir", Path: name, Err: err}
|
||||
}
|
||||
entries := m.dirEntries(cleaned)
|
||||
if entries == nil {
|
||||
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (m assetMapFS) dirEntries(dir string) []fs.DirEntry {
|
||||
children := map[string]assetDirEntry{}
|
||||
prefix := ""
|
||||
if dir != "." {
|
||||
prefix = dir + "/"
|
||||
}
|
||||
for name, data := range m {
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimPrefix(name, prefix)
|
||||
if rest == "" {
|
||||
continue
|
||||
}
|
||||
childName, _, hasSlash := strings.Cut(rest, "/")
|
||||
entry := assetDirEntry{name: childName, dir: hasSlash}
|
||||
if !hasSlash {
|
||||
entry.size = int64(len(data))
|
||||
}
|
||||
children[childName] = entry
|
||||
}
|
||||
if len(children) == 0 {
|
||||
if dir == "." {
|
||||
return []fs.DirEntry{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(children))
|
||||
for name := range children {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
entries := make([]fs.DirEntry, 0, len(names))
|
||||
for _, name := range names {
|
||||
entries = append(entries, children[name])
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func cleanOpenPath(name string) (string, error) {
|
||||
if name == "." {
|
||||
return ".", nil
|
||||
}
|
||||
return cleanAssetPath(name)
|
||||
}
|
||||
|
||||
type assetFile struct {
|
||||
reader *bytes.Reader
|
||||
info assetFileInfo
|
||||
}
|
||||
|
||||
func (f *assetFile) Stat() (fs.FileInfo, error) { return f.info, nil }
|
||||
func (f *assetFile) Read(p []byte) (int, error) { return f.reader.Read(p) }
|
||||
func (f *assetFile) Close() error { return nil }
|
||||
|
||||
type assetDir struct {
|
||||
name string
|
||||
offset int
|
||||
entries []fs.DirEntry
|
||||
}
|
||||
|
||||
func (d *assetDir) Stat() (fs.FileInfo, error) { return assetFileInfo{name: d.name, dir: true}, nil }
|
||||
func (d *assetDir) Read([]byte) (int, error) { return 0, fmt.Errorf("cannot read directory") }
|
||||
func (d *assetDir) Close() error { return nil }
|
||||
func (d *assetDir) ReadDir(n int) ([]fs.DirEntry, error) {
|
||||
if d.offset >= len(d.entries) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
end := len(d.entries)
|
||||
if n > 0 && d.offset+n < end {
|
||||
end = d.offset + n
|
||||
}
|
||||
out := append([]fs.DirEntry(nil), d.entries[d.offset:end]...)
|
||||
d.offset = end
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type assetDirEntry struct {
|
||||
name string
|
||||
dir bool
|
||||
size int64
|
||||
}
|
||||
|
||||
func (e assetDirEntry) Name() string { return e.name }
|
||||
func (e assetDirEntry) IsDir() bool { return e.dir }
|
||||
func (e assetDirEntry) Type() fs.FileMode { return e.InfoMode().Type() }
|
||||
func (e assetDirEntry) Info() (fs.FileInfo, error) {
|
||||
return assetFileInfo{name: e.name, dir: e.dir, size: e.size}, nil
|
||||
}
|
||||
func (e assetDirEntry) InfoMode() fs.FileMode {
|
||||
if e.dir {
|
||||
return fs.ModeDir | 0o555
|
||||
}
|
||||
return 0o444
|
||||
}
|
||||
|
||||
type assetFileInfo struct {
|
||||
name string
|
||||
dir bool
|
||||
size int64
|
||||
}
|
||||
|
||||
func (i assetFileInfo) Name() string { return i.name }
|
||||
func (i assetFileInfo) Size() int64 { return i.size }
|
||||
func (i assetFileInfo) Mode() fs.FileMode { return assetDirEntry{dir: i.dir}.InfoMode() }
|
||||
func (i assetFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (i assetFileInfo) IsDir() bool { return i.dir }
|
||||
func (i assetFileInfo) Sys() any { return nil }
|
||||
|
||||
@@ -142,7 +142,7 @@ func (c *PromptKitClient) CompleteStructured(ctx context.Context, req contracts.
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return contracts.StructuredCompletionResponse{}, ctxErr
|
||||
}
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("prepare PromptKit prompt %q: %w", promptID, redactPromptKitError(err))
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("prepare PromptKit prompt %q: %v", promptID, redactPromptKitError(err))
|
||||
}
|
||||
defer prepared.Discard()
|
||||
preparedDetails := prepared.Details()
|
||||
@@ -169,7 +169,7 @@ func (c *PromptKitClient) CompleteStructured(ctx context.Context, req contracts.
|
||||
redactPromptKitError(err),
|
||||
)
|
||||
}
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("run PromptKit prompt %q: %w", promptID, redactPromptKitError(err))
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("run PromptKit prompt %q: %v", promptID, redactPromptKitError(err))
|
||||
}
|
||||
if result == nil {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("run PromptKit prompt %q: %w: empty result", promptID, contracts.ErrInvalidStructuredOutput)
|
||||
@@ -427,17 +427,13 @@ func redactPromptKitError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return redactedProviderError{err: err}
|
||||
return sanitizedProviderDiagnostic{message: bearerTokenPattern.ReplaceAllString(err.Error(), "Bearer "+secretReplacement)}
|
||||
}
|
||||
|
||||
type redactedProviderError struct {
|
||||
err error
|
||||
type sanitizedProviderDiagnostic struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (e redactedProviderError) Error() string {
|
||||
return bearerTokenPattern.ReplaceAllString(e.err.Error(), "Bearer "+secretReplacement)
|
||||
}
|
||||
|
||||
func (e redactedProviderError) Unwrap() error {
|
||||
return e.err
|
||||
func (e sanitizedProviderDiagnostic) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
@@ -741,6 +741,11 @@ model: local-model
|
||||
!strings.Contains(err.Error(), promptkit.BackendLocal) {
|
||||
t.Fatalf("CompleteStructured() without registration error = %v, want preparation failure with local backend context", err)
|
||||
}
|
||||
for _, sentinel := range []error{promptkit.ErrProfileLoad, promptkit.ErrInvalidRequest, promptkit.ErrPromptNotFound} {
|
||||
if errors.Is(err, sentinel) {
|
||||
t.Fatalf("preparation failure exposes PromptKit sentinel %v: %v", sentinel, err)
|
||||
}
|
||||
}
|
||||
if providerCalls.Load() != 1 {
|
||||
t.Fatalf("provider calls after missing-registration failure = %d, want 1", providerCalls.Load())
|
||||
}
|
||||
@@ -814,7 +819,8 @@ func TestPromptKitClientDecodeFailureReturnsRawResponse(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPromptKitClientProviderFailureIncludesContextAndRedactsBearerToken(t *testing.T) {
|
||||
client := newTestPromptKitClient(t, &fakePromptKitLLM{err: errors.New("provider failed with Bearer secret-token")})
|
||||
providerErr := &credentialBearingProviderError{}
|
||||
client := newTestPromptKitClient(t, &fakePromptKitLLM{err: providerErr})
|
||||
|
||||
var out map[string]any
|
||||
resp, err := client.CompleteStructured(context.Background(), contracts.StructuredCompletionRequest{
|
||||
@@ -836,6 +842,21 @@ func TestPromptKitClientProviderFailureIncludesContextAndRedactsBearerToken(t *t
|
||||
if strings.Contains(err.Error(), "secret-token") || !strings.Contains(err.Error(), "Bearer [REDACTED]") {
|
||||
t.Fatalf("error = %q, want redacted bearer token", err.Error())
|
||||
}
|
||||
for current := err; current != nil; current = errors.Unwrap(current) {
|
||||
if strings.Contains(current.Error(), "secret-token") {
|
||||
t.Fatalf("error chain exposes credential: %v", err)
|
||||
}
|
||||
}
|
||||
if errors.Unwrap(err) != nil {
|
||||
t.Fatalf("provider failure must not expose a wrapped diagnostic: %v", err)
|
||||
}
|
||||
var recoveredProviderErr *credentialBearingProviderError
|
||||
if errors.As(err, &recoveredProviderErr) {
|
||||
t.Fatalf("provider error escaped the adapter: %v", err)
|
||||
}
|
||||
if errors.Is(err, promptkit.ErrLLMGenerate) {
|
||||
t.Fatalf("provider failure exposes PromptKit generation sentinel: %v", err)
|
||||
}
|
||||
if resp.Debug != nil {
|
||||
t.Fatalf("debug material = %#v, want none for provider failure without result", resp.Debug)
|
||||
}
|
||||
@@ -1214,6 +1235,12 @@ type fakePromptKitLLM struct {
|
||||
maxInFlight int32
|
||||
}
|
||||
|
||||
type credentialBearingProviderError struct{}
|
||||
|
||||
func (*credentialBearingProviderError) Error() string {
|
||||
return "provider failed with Bearer secret-token"
|
||||
}
|
||||
|
||||
func (f *fakePromptKitLLM) Generate(ctx context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||
f.mu.Lock()
|
||||
f.last = req
|
||||
|
||||
@@ -83,6 +83,9 @@ func (s *Scheduler) Run(ctx context.Context, fn func(context.Context) error) err
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,79 @@ func TestSchedulerRunReleasesPermitAfterError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type cancelAfterAdmissionContext struct {
|
||||
done chan struct{}
|
||||
checks atomic.Int32
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newCancelAfterAdmissionContext() *cancelAfterAdmissionContext {
|
||||
return &cancelAfterAdmissionContext{done: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (*cancelAfterAdmissionContext) Deadline() (time.Time, bool) { return time.Time{}, false }
|
||||
|
||||
func (c *cancelAfterAdmissionContext) Done() <-chan struct{} { return c.done }
|
||||
|
||||
func (c *cancelAfterAdmissionContext) Err() error {
|
||||
if c.checks.Add(1) == 1 {
|
||||
return nil
|
||||
}
|
||||
c.once.Do(func() { close(c.done) })
|
||||
return context.Canceled
|
||||
}
|
||||
|
||||
func (*cancelAfterAdmissionContext) Value(any) any { return nil }
|
||||
|
||||
func TestSchedulerDoesNotDispatchCanceledAdmissionAndReleasesNextWaiter(t *testing.T) {
|
||||
s, err := NewScheduler(1)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScheduler: %v", err)
|
||||
}
|
||||
hold, err := s.Acquire(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Acquire: %v", err)
|
||||
}
|
||||
defer hold()
|
||||
|
||||
ctx := newCancelAfterAdmissionContext()
|
||||
var canceledCalls atomic.Int32
|
||||
canceledErr := make(chan error, 1)
|
||||
go func() {
|
||||
canceledErr <- s.Run(ctx, func(context.Context) error {
|
||||
canceledCalls.Add(1)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
waitForQueueDepth(t, s, 1)
|
||||
|
||||
nextStarted := make(chan struct{})
|
||||
nextErr := make(chan error, 1)
|
||||
go func() {
|
||||
nextErr <- s.Run(context.Background(), func(context.Context) error {
|
||||
close(nextStarted)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
waitForQueueDepth(t, s, 2)
|
||||
|
||||
hold()
|
||||
if err := <-canceledErr; !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run() error = %v, want context.Canceled", err)
|
||||
}
|
||||
if got := canceledCalls.Load(); got != 0 {
|
||||
t.Fatalf("canceled callback calls = %d, want none", got)
|
||||
}
|
||||
select {
|
||||
case <-nextStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for next FIFO waiter")
|
||||
}
|
||||
if err := <-nextErr; err != nil {
|
||||
t.Fatalf("next Run() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForAtomicAtLeast(t *testing.T, value *int32, want int32) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
|
||||
@@ -69,6 +69,7 @@ type artifactCodecEntry struct {
|
||||
encodeCandidate func(any) ([]byte, error)
|
||||
metadata func(any) (map[string]any, error)
|
||||
decode func([]byte) (any, error)
|
||||
decodeCandidate func([]byte) (any, error)
|
||||
}
|
||||
|
||||
func NewArtifactCodecRegistry() *ArtifactCodecRegistry {
|
||||
@@ -77,7 +78,7 @@ func NewArtifactCodecRegistry() *ArtifactCodecRegistry {
|
||||
|
||||
// RegisterArtifactCodec registers one codec for T. The concrete type is kept
|
||||
// private and checked at every erased encode boundary.
|
||||
func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contracts.ArtifactCodec[T]) error {
|
||||
func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contracts.CandidateArtifactCodec[T]) error {
|
||||
if registry == nil {
|
||||
return fmt.Errorf("artifact codec registry must not be nil")
|
||||
}
|
||||
@@ -119,6 +120,13 @@ func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contrac
|
||||
}
|
||||
return decoded, nil
|
||||
},
|
||||
decodeCandidate: func(content []byte) (any, error) {
|
||||
decoded, err := codec.DecodeCandidate(append([]byte(nil), content...))
|
||||
if err != nil {
|
||||
return nil, &ArtifactCodecOperationError{Operation: "decode candidate", Kind: spec.Kind, Err: err}
|
||||
}
|
||||
return decoded, nil
|
||||
},
|
||||
}
|
||||
entry.encodeCandidate = func(value any) ([]byte, error) {
|
||||
typed, err := exactTypedValue[T]("encode candidate artifact", value)
|
||||
|
||||
@@ -14,7 +14,13 @@ import (
|
||||
)
|
||||
|
||||
type codecNotes struct {
|
||||
Items []string `json:"items"`
|
||||
Items []string `json:"items"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Details *codecNoteDetails `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type codecNoteDetails struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type codecScore struct {
|
||||
@@ -24,12 +30,13 @@ type codecScore struct {
|
||||
type codecNotesAlias codecNotes
|
||||
|
||||
type testArtifactCodec[T any] struct {
|
||||
kind contracts.ArtifactKind
|
||||
schema contracts.ArtifactSchema
|
||||
mediaType string
|
||||
encodeFunc func(T) ([]byte, error)
|
||||
candidateFunc func(T) ([]byte, error)
|
||||
decodeFunc func([]byte) (T, error)
|
||||
kind contracts.ArtifactKind
|
||||
schema contracts.ArtifactSchema
|
||||
mediaType string
|
||||
encodeFunc func(T) ([]byte, error)
|
||||
candidateFunc func(T) ([]byte, error)
|
||||
decodeFunc func([]byte) (T, error)
|
||||
candidateDecodeFunc func([]byte) (T, error)
|
||||
}
|
||||
|
||||
func (c testArtifactCodec[T]) Kind() contracts.ArtifactKind { return c.kind }
|
||||
@@ -43,8 +50,15 @@ func (c testArtifactCodec[T]) EncodeCandidate(value T) ([]byte, error) {
|
||||
}
|
||||
func (c testArtifactCodec[T]) Encode(value T) ([]byte, error) { return c.encodeFunc(value) }
|
||||
func (c testArtifactCodec[T]) Decode(content []byte) (T, error) { return c.decodeFunc(content) }
|
||||
func (c testArtifactCodec[T]) DecodeCandidate(content []byte) (T, error) {
|
||||
if c.candidateDecodeFunc != nil {
|
||||
return c.candidateDecodeFunc(content)
|
||||
}
|
||||
return c.decodeFunc(content)
|
||||
}
|
||||
|
||||
var _ contracts.ArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{}
|
||||
var _ contracts.CandidateArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{}
|
||||
|
||||
func TestArtifactCodecRegistryStoresHeterogeneousExactTypes(t *testing.T) {
|
||||
registry := NewArtifactCodecRegistry()
|
||||
@@ -138,6 +152,44 @@ func TestArtifactCodecRegistryKeepsCandidateAndFinalEncodingDistinct(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryKeepsCandidateAndFinalDecodingDistinct(t *testing.T) {
|
||||
candidateCalls, finalCalls := 0, 0
|
||||
codec := notesCodec()
|
||||
codec.candidateDecodeFunc = func([]byte) (codecNotes, error) {
|
||||
candidateCalls++
|
||||
return codecNotes{Items: []string{"candidate"}}, nil
|
||||
}
|
||||
codec.decodeFunc = func([]byte) (codecNotes, error) {
|
||||
finalCalls++
|
||||
return codecNotes{Items: []string{"final"}}, nil
|
||||
}
|
||||
registry := NewArtifactCodecRegistry()
|
||||
if err := RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
|
||||
}
|
||||
entry, _, err := registry.entry(codec.kind)
|
||||
if err != nil {
|
||||
t.Fatalf("entry() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
candidate, err := entry.decodeCandidate([]byte(`{"items":["one"]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("candidate decode error = %v", err)
|
||||
}
|
||||
candidateWant := codecNotes{Items: []string{"candidate"}}
|
||||
if !reflect.DeepEqual(candidate, candidateWant) || candidateCalls != 1 || finalCalls != 0 {
|
||||
t.Fatalf("candidate decode = %#v, calls = candidate %d, final %d", candidate, candidateCalls, finalCalls)
|
||||
}
|
||||
decoded, err := entry.decode([]byte(`{"items":["one"]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("final decode error = %v", err)
|
||||
}
|
||||
finalWant := codecNotes{Items: []string{"final"}}
|
||||
if !reflect.DeepEqual(decoded, finalWant) || candidateCalls != 1 || finalCalls != 1 {
|
||||
t.Fatalf("final decode = %#v, calls = candidate %d, final %d", decoded, candidateCalls, finalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryStoresValidatedSchemaMetadata(t *testing.T) {
|
||||
registry := NewArtifactCodecRegistry()
|
||||
codec := notesCodec()
|
||||
@@ -164,7 +216,7 @@ func TestArtifactCodecRegistryStoresValidatedSchemaMetadata(t *testing.T) {
|
||||
|
||||
spec.Schema.JSONSchema[0] = '['
|
||||
again, _ := registry.Spec("test/notes")
|
||||
if string(again.Schema.JSONSchema) != `{"additionalProperties":false,"properties":{"items":{"items":{"type":"string"},"type":"array"}},"required":["items"],"type":"object"}` {
|
||||
if string(again.Schema.JSONSchema) != `{"additionalProperties":false,"properties":{"details":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"},"items":{"items":{"type":"string"},"type":"array"},"labels":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["items"],"type":"object"}` {
|
||||
t.Fatalf("stored JSON Schema changed through Spec result: %q", again.Schema.JSONSchema)
|
||||
}
|
||||
}
|
||||
@@ -267,6 +319,28 @@ func TestArtifactCodecRegistryWrapsEncodeFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryWrapsCandidateDecodeFailure(t *testing.T) {
|
||||
cause := errors.New("cannot decode candidate notes")
|
||||
codec := notesCodec()
|
||||
codec.candidateDecodeFunc = func([]byte) (codecNotes, error) {
|
||||
return codecNotes{}, cause
|
||||
}
|
||||
registry := NewArtifactCodecRegistry()
|
||||
if err := RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
|
||||
}
|
||||
entry, _, err := registry.entry(codec.kind)
|
||||
if err != nil {
|
||||
t.Fatalf("entry() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
_, err = entry.decodeCandidate([]byte(`{"items":["one"]}`))
|
||||
var operationErr *ArtifactCodecOperationError
|
||||
if !errors.As(err, &operationErr) || operationErr.Operation != "decode candidate" || !errors.Is(err, cause) {
|
||||
t.Fatalf("candidate decode error = %T %v, want typed wrapping error", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
|
||||
shared := []byte(`{"items":["one"]}`)
|
||||
codec := notesCodec()
|
||||
@@ -275,6 +349,10 @@ func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
|
||||
content[0] = '['
|
||||
return codecNotes{Items: []string{"one"}}, nil
|
||||
}
|
||||
codec.candidateDecodeFunc = func(content []byte) (codecNotes, error) {
|
||||
content[0] = '['
|
||||
return codecNotes{Items: []string{"candidate"}}, nil
|
||||
}
|
||||
registry := NewArtifactCodecRegistry()
|
||||
if err := RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
|
||||
@@ -295,6 +373,18 @@ func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
|
||||
if !bytes.Equal(artifact.Content, before) {
|
||||
t.Fatalf("serialized content changed during decode: %q", artifact.Content)
|
||||
}
|
||||
candidateContent := []byte(`{"items":["candidate"]}`)
|
||||
candidateBefore := append([]byte(nil), candidateContent...)
|
||||
entry, _, err := registry.entry("test/notes")
|
||||
if err != nil {
|
||||
t.Fatalf("entry() error = %v, want nil", err)
|
||||
}
|
||||
if _, err := entry.decodeCandidate(candidateContent); err != nil {
|
||||
t.Fatalf("candidate decode error = %v, want nil", err)
|
||||
}
|
||||
if !bytes.Equal(candidateContent, candidateBefore) {
|
||||
t.Fatalf("candidate content changed during decode: %q", candidateContent)
|
||||
}
|
||||
}
|
||||
|
||||
func notesCodec() testArtifactCodec[codecNotes] {
|
||||
@@ -304,7 +394,7 @@ func notesCodec() testArtifactCodec[codecNotes] {
|
||||
ID: "notes.v1",
|
||||
Name: "notes",
|
||||
Version: "v1",
|
||||
JSONSchema: []byte(`{"additionalProperties":false,"properties":{"items":{"items":{"type":"string"},"type":"array"}},"required":["items"],"type":"object"}`),
|
||||
JSONSchema: []byte(`{"additionalProperties":false,"properties":{"details":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"},"items":{"items":{"type":"string"},"type":"array"},"labels":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["items"],"type":"object"}`),
|
||||
},
|
||||
mediaType: "application/json",
|
||||
encodeFunc: func(value codecNotes) ([]byte, error) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
@@ -34,32 +35,6 @@ func NoopDebugRecorder() DebugRecorder { return noopDebugRecorder{} }
|
||||
func (noopDebugRecorder) Enabled() bool { return false }
|
||||
func (noopDebugRecorder) WriteJSON(string, any) error { return nil }
|
||||
func (noopDebugRecorder) WriteBytes(string, []byte) error { return nil }
|
||||
func debugPathComponent(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "_"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '-' || r == '_' || r == '.':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteString(fmt.Sprintf("~%x", r))
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if out == "." || out == ".." || strings.Contains(out, "..") {
|
||||
return "_"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type debugTimedEnvelope struct {
|
||||
Stage string `json:"stage,omitempty"`
|
||||
@@ -242,7 +217,7 @@ func (client *debugLLMClient) CompleteStructured(ctx context.Context, req contra
|
||||
}
|
||||
|
||||
scopePrefix := cleanDebugPath(req.StageName)
|
||||
if scopePrefix == "_" {
|
||||
if req.StageName == "" {
|
||||
scopePrefix = "llm"
|
||||
}
|
||||
if scope := debugLLMScopeFromContext(ctx); scope != nil {
|
||||
@@ -310,7 +285,6 @@ func withDebugLLMScope(ctx context.Context, prefix string) (context.Context, *de
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
prefix = cleanDebugPath(prefix)
|
||||
scope := &debugLLMScope{
|
||||
prefix: prefix,
|
||||
parent: debugLLMScopeFromContext(ctx),
|
||||
@@ -322,7 +296,6 @@ func withIsolatedDebugLLMScope(ctx context.Context, prefix string) (context.Cont
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
prefix = cleanDebugPath(prefix)
|
||||
scope := &debugLLMScope{prefix: prefix}
|
||||
return context.WithValue(ctx, debugLLMScopeContextKey{}, scope), scope
|
||||
}
|
||||
@@ -362,15 +335,12 @@ func (scope *debugLLMScope) references() []debugLLMCallReference {
|
||||
}
|
||||
|
||||
func cleanDebugPath(value string) string {
|
||||
parts := strings.Split(path.Clean(strings.TrimSpace(value)), "/")
|
||||
parts := strings.Split(value, "/")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
out = append(out, debugPathComponent(part))
|
||||
out = append(out, fileio.EncodePathComponent(part))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return "_"
|
||||
}
|
||||
return path.Join(out...)
|
||||
return strings.Join(out, "/")
|
||||
}
|
||||
|
||||
func debugFirstNonEmptyString(values ...string) string {
|
||||
|
||||
@@ -1,13 +1,52 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func TestCleanDebugPathPreservesRawComponents(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{value: "", want: "%"},
|
||||
{value: ".", want: "%2E"},
|
||||
{value: "..", want: "%2E%2E"},
|
||||
{value: "a//b", want: "a/%/b"},
|
||||
{value: "/a/", want: "%/a/%"},
|
||||
{value: " a ", want: "%20a%20"},
|
||||
} {
|
||||
if got := cleanDebugPath(test.value); got != test.want {
|
||||
t.Errorf("cleanDebugPath(%q) = %q, want %q", test.value, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugLLMPathsKeepDotIdentitiesDistinct(t *testing.T) {
|
||||
recorder := newCapturedDebugRecorder()
|
||||
client := WithDebugLLMRecording(attemptDebugLLM{}, recorder)
|
||||
for _, test := range []struct {
|
||||
stageName string
|
||||
path string
|
||||
}{
|
||||
{stageName: ".", path: "%2E/response-0001.json"},
|
||||
{stageName: "..", path: "%2E%2E/response-0002.json"},
|
||||
} {
|
||||
if _, err := client.CompleteStructured(context.Background(), contracts.StructuredCompletionRequest{StageName: test.stageName}, nil); err != nil {
|
||||
t.Fatalf("CompleteStructured(%q): %v", test.stageName, err)
|
||||
}
|
||||
if !recorder.has(test.path) {
|
||||
t.Errorf("debug artifact %q was not written; names = %#v", test.path, recorder.names())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugSourceDocumentPreservesUnitReferences(t *testing.T) {
|
||||
doc := validSourceDocument()
|
||||
envelope := debugSourceDocumentEnvelope(doc)
|
||||
|
||||
@@ -18,6 +18,16 @@ type referenceTargetKey struct {
|
||||
Stage ModuleStage
|
||||
}
|
||||
|
||||
type generatedOutputKey struct {
|
||||
stepID string
|
||||
laneID string
|
||||
}
|
||||
|
||||
type indexedGeneratedOutput struct {
|
||||
count int
|
||||
output contracts.SerializedOutput
|
||||
}
|
||||
|
||||
func keyForReferenceTarget(target ResolvedReferenceTarget) referenceTargetKey {
|
||||
return referenceTargetKey{StepID: target.StepID, LaneID: target.LaneID, Stage: target.Stage}
|
||||
}
|
||||
@@ -40,7 +50,8 @@ func buildStepReferenceSets(input RunInput, step PreparedPipelineStep, outputs [
|
||||
}
|
||||
sets := make(map[referenceTargetKey]contracts.ReferenceSet)
|
||||
var provenance []artifacts.ReferenceProvenance
|
||||
canonical := make(map[string]contracts.ReferenceItem)
|
||||
outputsByProducer := indexGeneratedOutputs(outputs)
|
||||
canonical := make(map[generatedOutputKey]contracts.ReferenceItem)
|
||||
for _, prepared := range step.lanes {
|
||||
lane := prepared.resolved
|
||||
for _, target := range []ResolvedReferenceTarget{lane.ExtractReferences, lane.MergeReferences, lane.NormalizeReferences} {
|
||||
@@ -51,7 +62,7 @@ func buildStepReferenceSets(input RunInput, step PreparedPipelineStep, outputs [
|
||||
continue
|
||||
}
|
||||
generated = true
|
||||
item, err := generatedReferenceItem(input, binding, outputs, canonical)
|
||||
item, err := generatedReferenceItem(input, binding, outputsByProducer, canonical)
|
||||
if err != nil {
|
||||
return nil, nil, contextualHandoffError(input, target, binding, err)
|
||||
}
|
||||
@@ -101,31 +112,43 @@ func contextualHandoffError(input RunInput, target ResolvedReferenceTarget, bind
|
||||
return fmt.Errorf("pipeline %q step %q lane %q %s generated dependency %q from %q/%q: %w", input.pipeline.ID, target.StepID, target.LaneID, target.Stage, binding.SlotName, binding.Artifact.Step, binding.Artifact.Lane, err)
|
||||
}
|
||||
|
||||
func generatedReferenceItem(input RunInput, binding ReferenceBinding, outputs []contracts.SerializedOutput, cache map[string]contracts.ReferenceItem) (contracts.ReferenceItem, error) {
|
||||
func generatedOutputKeyFor(stepID, laneID string) generatedOutputKey {
|
||||
return generatedOutputKey{stepID: strings.TrimSpace(stepID), laneID: strings.TrimSpace(laneID)}
|
||||
}
|
||||
|
||||
func indexGeneratedOutputs(outputs []contracts.SerializedOutput) map[generatedOutputKey]indexedGeneratedOutput {
|
||||
indexed := make(map[generatedOutputKey]indexedGeneratedOutput, len(outputs))
|
||||
for _, output := range outputs {
|
||||
key := generatedOutputKeyFor(output.StepID, output.LaneID)
|
||||
entry := indexed[key]
|
||||
entry.count++
|
||||
if entry.count == 1 {
|
||||
entry.output = output
|
||||
}
|
||||
indexed[key] = entry
|
||||
}
|
||||
return indexed
|
||||
}
|
||||
|
||||
func generatedReferenceItem(input RunInput, binding ReferenceBinding, outputsByProducer map[generatedOutputKey]indexedGeneratedOutput, cache map[generatedOutputKey]contracts.ReferenceItem) (contracts.ReferenceItem, error) {
|
||||
selector := binding.Artifact
|
||||
if selector == nil {
|
||||
return contracts.ReferenceItem{}, fmt.Errorf("generated reference selector must not be nil")
|
||||
}
|
||||
stepID := strings.TrimSpace(selector.Step)
|
||||
laneID := strings.TrimSpace(selector.Lane)
|
||||
cacheKey := stepID + "\x00" + laneID
|
||||
if item, ok := cache[cacheKey]; ok {
|
||||
producerKey := generatedOutputKeyFor(selector.Step, selector.Lane)
|
||||
if item, ok := cache[producerKey]; ok {
|
||||
item.SlotName = strings.TrimSpace(binding.SlotName)
|
||||
item.BindingSource = strings.TrimSpace(binding.BindingSource)
|
||||
return contracts.CloneReferenceItem(item), nil
|
||||
}
|
||||
matches := make([]contracts.SerializedOutput, 0, 1)
|
||||
for _, output := range outputs {
|
||||
if strings.TrimSpace(output.StepID) == stepID && strings.TrimSpace(output.LaneID) == laneID {
|
||||
matches = append(matches, output)
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
matched, ok := outputsByProducer[producerKey]
|
||||
if !ok {
|
||||
return contracts.ReferenceItem{}, fmt.Errorf("producer has no accepted normalized output")
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return contracts.ReferenceItem{}, fmt.Errorf("producer has %d accepted normalized outputs; exactly one is required", len(matches))
|
||||
if matched.count > 1 {
|
||||
return contracts.ReferenceItem{}, fmt.Errorf("producer has %d accepted normalized outputs; exactly one is required", matched.count)
|
||||
}
|
||||
stepID, laneID := producerKey.stepID, producerKey.laneID
|
||||
producer, ok := findResolvedLane(input.pipeline, stepID, laneID)
|
||||
if !ok {
|
||||
return contracts.ReferenceItem{}, fmt.Errorf("producer lane is not present in the resolved pipeline")
|
||||
@@ -133,7 +156,7 @@ func generatedReferenceItem(input RunInput, binding ReferenceBinding, outputs []
|
||||
if input.Prepared == nil || input.Prepared.artifactCodecs == nil {
|
||||
return contracts.ReferenceItem{}, fmt.Errorf("artifact codec registry is unavailable")
|
||||
}
|
||||
serialized := contracts.CloneSerializedArtifact(matches[0].Artifact)
|
||||
serialized := contracts.CloneSerializedArtifact(matched.output.Artifact)
|
||||
if serialized.Kind != producer.ArtifactKind {
|
||||
return contracts.ReferenceItem{}, fmt.Errorf("producer artifact kind %q does not match resolved kind %q", serialized.Kind, producer.ArtifactKind)
|
||||
}
|
||||
@@ -172,7 +195,7 @@ func generatedReferenceItem(input RunInput, binding ReferenceBinding, outputs []
|
||||
cacheItem := contracts.CloneReferenceItem(item)
|
||||
cacheItem.SlotName = ""
|
||||
cacheItem.BindingSource = ""
|
||||
cache[cacheKey] = cacheItem
|
||||
cache[producerKey] = cacheItem
|
||||
return item, nil
|
||||
}
|
||||
|
||||
@@ -243,6 +266,7 @@ type generatedReferenceFingerprintIdentity struct {
|
||||
SchemaDigest string `json:"schema_digest"`
|
||||
MediaType string `json:"media_type"`
|
||||
ContentDigest string `json:"content_digest"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
// generatedReferenceDependencies returns the canonical semantic dependency
|
||||
@@ -271,6 +295,7 @@ func generatedReferenceDependencies(set contracts.ReferenceSet) []CheckpointFing
|
||||
SchemaDigest: contracts.DigestArtifactSchema(item.ArtifactSchema),
|
||||
MediaType: item.MediaType,
|
||||
ContentDigest: item.Digest,
|
||||
SizeBytes: item.SizeBytes,
|
||||
}
|
||||
encoded, err := json.Marshal(identity)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package pipeline
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -106,6 +107,29 @@ func TestBuildStepReferenceSetsCanonicalizesAndClonesFanout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStepReferenceSetsIndexesManyOutputsForFanout(t *testing.T) {
|
||||
input, step, producerOutput := handoffFixture(t, codecNotes{Items: []string{"first"}})
|
||||
outputs := make([]contracts.SerializedOutput, 0, 65)
|
||||
for index := range 64 {
|
||||
outputs = append(outputs, contracts.SerializedOutput{StepID: fmt.Sprintf("unrelated-step-%d", index), LaneID: "unrelated-lane"})
|
||||
}
|
||||
outputs = append(outputs, producerOutput)
|
||||
|
||||
sets, provenance, err := buildStepReferenceSets(input, step, outputs)
|
||||
if err != nil {
|
||||
t.Fatalf("buildStepReferenceSets() error = %v", err)
|
||||
}
|
||||
if len(sets) != 3 || len(provenance) != 3 {
|
||||
t.Fatalf("reference sets/provenance = %d/%d, want 3/3", len(sets), len(provenance))
|
||||
}
|
||||
for target, set := range sets {
|
||||
item := set.Slots["producer-output"].Items[0]
|
||||
if item.Producer.StepID != "step-1" || item.Producer.LaneID != "notes" || string(item.Content) == "" {
|
||||
t.Fatalf("target %v item = %#v, want canonical producer artifact", target, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedReferenceAcceptsTypedEmptyCollection(t *testing.T) {
|
||||
input, step, producerOutput := handoffFixture(t, codecNotes{})
|
||||
sets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{producerOutput})
|
||||
@@ -128,6 +152,12 @@ func TestGeneratedReferenceRejectsInvalidProducerOutputsDeterministically(t *tes
|
||||
{name: "multiple", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
|
||||
return []contracts.SerializedOutput{output, output}
|
||||
}, want: "exactly one is required"},
|
||||
{name: "multiple after identifier normalization", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
|
||||
duplicate := contracts.CloneSerializedOutput(output)
|
||||
duplicate.StepID = " step-1 "
|
||||
duplicate.LaneID = " notes "
|
||||
return []contracts.SerializedOutput{output, duplicate}
|
||||
}, want: "producer has 2 accepted normalized outputs; exactly one is required"},
|
||||
{name: "kind mismatch", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
|
||||
return []contracts.SerializedOutput{output}
|
||||
}, mutate: func(target *ResolvedReferenceTarget, _ *contracts.SerializedOutput) {
|
||||
@@ -167,6 +197,17 @@ func TestGeneratedReferenceRejectsInvalidProducerOutputsDeterministically(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIndexGeneratedOutputs(b *testing.B) {
|
||||
outputs := make([]contracts.SerializedOutput, 0, 1024)
|
||||
for index := range 1024 {
|
||||
outputs = append(outputs, contracts.SerializedOutput{StepID: fmt.Sprintf("step-%d", index), LaneID: fmt.Sprintf("lane-%d", index)})
|
||||
}
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
_ = indexGeneratedOutputs(outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedReferenceFingerprintChangesWithCanonicalContent(t *testing.T) {
|
||||
input, step, first := handoffFixture(t, codecNotes{Items: []string{"first"}})
|
||||
firstSets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{first})
|
||||
@@ -185,6 +226,27 @@ func TestGeneratedReferenceFingerprintChangesWithCanonicalContent(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedReferenceFingerprintChangesWithCanonicalSize(t *testing.T) {
|
||||
input, step, producerOutput := handoffFixture(t, codecNotes{Items: []string{"first"}})
|
||||
sets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{producerOutput})
|
||||
if err != nil {
|
||||
t.Fatalf("build reference set: %v", err)
|
||||
}
|
||||
key := keyForReferenceTarget(step.lanes[0].resolved.ExtractReferences)
|
||||
firstDeps := generatedReferenceDependencies(sets[key])
|
||||
changed := CloneReferenceSet(sets[key])
|
||||
item := changed.Slots["producer-output"].Items[0]
|
||||
item.SizeBytes++
|
||||
changed.Slots["producer-output"] = contracts.ResolvedReferenceSlot{
|
||||
Slot: changed.Slots["producer-output"].Slot,
|
||||
Items: []contracts.ReferenceItem{item},
|
||||
}
|
||||
secondDeps := generatedReferenceDependencies(changed)
|
||||
if reflect.DeepEqual(firstDeps, secondDeps) {
|
||||
t.Fatalf("generated dependencies = %#v, want size-sensitive fingerprint", firstDeps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerHandsOffAcceptedNormalizedOutputBeforeConsumerLanes(t *testing.T) {
|
||||
input, _, _ := handoffFixture(t, codecNotes{Items: []string{"first"}})
|
||||
prepared := input.Prepared
|
||||
|
||||
@@ -305,6 +305,7 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
|
||||
if name != "extract" {
|
||||
return
|
||||
}
|
||||
request.Options["nested"].(map[string]any)["value"] = "mutated by extractor builder"
|
||||
slot := request.References.Slots["extract"]
|
||||
slot.Items[0].Content = []byte("mutated by extractor builder")
|
||||
request.References.Slots["extract"] = slot
|
||||
@@ -318,6 +319,7 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
|
||||
resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet = constructionReferenceSet("extract", "extract reference")
|
||||
resolved.Steps[0].ArtifactLanes[0].MergeReferences.ReferenceSet = constructionReferenceSet("merge", "merge reference")
|
||||
resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.ReferenceSet = constructionReferenceSet("normalize", "normalize reference")
|
||||
resolved.Steps[0].ArtifactLanes[0].Extract.Options = constructionBuildRequest().Options
|
||||
|
||||
prepared, err := Prepare(resolved, registries, ModuleDependencies{})
|
||||
if err != nil {
|
||||
@@ -339,6 +341,15 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
|
||||
if got := constructionReferenceContent(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" {
|
||||
t.Fatalf("resolved extract references = %q, want original content", got)
|
||||
}
|
||||
if got := constructionReferenceContent(prepared.resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" {
|
||||
t.Fatalf("prepared extract references = %q, want original content", got)
|
||||
}
|
||||
if got := resolved.Steps[0].ArtifactLanes[0].Extract.Options["nested"].(map[string]any)["value"]; got != "original" {
|
||||
t.Fatalf("resolved extract options = %#v, want original nested value", got)
|
||||
}
|
||||
if got := prepared.resolved.Steps[0].ArtifactLanes[0].Extract.Options["nested"].(map[string]any)["value"]; got != "original" {
|
||||
t.Fatalf("prepared extract options = %#v, want original nested value", got)
|
||||
}
|
||||
|
||||
_, err = prepared.Steps[0].lanes[0].typed.extract(context.Background(), prepared.Steps[0].lanes[0].typed.extractor, contracts.TypedExtractionRequest{
|
||||
References: CloneReferenceSet(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet),
|
||||
@@ -354,6 +365,163 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredBuildersReceiveIndependentBuildRequests(t *testing.T) {
|
||||
request := constructionBuildRequest()
|
||||
probe := buildRequestMutationProbe{t: t, want: cloneBuildRequest(request)}
|
||||
|
||||
inputs := NewInputAdapterRegistry()
|
||||
if err := inputs.RegisterBuilderWithSpec(testModuleSpec("input", StageInput), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.InputAdapter, error) {
|
||||
probe.observe(request)
|
||||
return &constructionInput{key: "input"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chunkers := NewChunkerRegistry()
|
||||
if err := chunkers.RegisterBuilderWithSpec(testModuleSpec("chunk", StageChunk), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Chunker, error) {
|
||||
probe.observe(request)
|
||||
return &typedTestChunker{key: "chunk"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outputs := NewOutputEncoderRegistry()
|
||||
if err := outputs.RegisterBuilderWithSpec(testModuleSpec("output", StageOutput), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.OutputEncoder, error) {
|
||||
probe.observe(request)
|
||||
return &typedTestOutput{key: "output"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
extractors := NewExtractorRegistry()
|
||||
extractSpec := testModuleSpec("extract", StageExtract)
|
||||
extractSpec.ArtifactKind = "test/notes"
|
||||
if err := RegisterExtractorBuilder(extractors, extractSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Extractor[codecNotes], error) {
|
||||
probe.observe(request)
|
||||
return typedTestExtractor[codecNotes]{key: "extract"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mergers := NewMergerRegistry()
|
||||
mergeSpec := testModuleSpec("merge", StageMerge)
|
||||
mergeSpec.ArtifactKind = "test/notes"
|
||||
if err := RegisterMergerBuilder(mergers, mergeSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Merger[codecNotes], error) {
|
||||
probe.observe(request)
|
||||
return typedTestMerger[codecNotes]{key: "merge"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normalizers := NewNormalizerRegistry()
|
||||
normalizeSpec := testModuleSpec("normalize", StageNormalize)
|
||||
normalizeSpec.ArtifactKind = "test/notes"
|
||||
if err := RegisterNormalizerBuilder(normalizers, normalizeSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Normalizer[codecNotes], error) {
|
||||
probe.observe(request)
|
||||
return typedTestNormalizer[codecNotes]{key: "normalize"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
validators := NewValidatorRegistry()
|
||||
if err := RegisterChunkValidatorBuilder(validators, ValidatorSpec{Key: "chunk-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.ChunkValidator, error) {
|
||||
probe.observe(request)
|
||||
return typedTestChunkValidator{key: "chunk-validator"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RegisterTypedValidatorBuilder(validators, "test/notes", ValidatorSpec{Key: "typed-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.TypedValidator[codecNotes], error) {
|
||||
probe.observe(request)
|
||||
return typedTestValidator[codecNotes]{key: "typed-validator"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RegisterSerializedValidatorBuilder(validators, SerializedValidatorSpec{ValidatorSpec: ValidatorSpec{Key: "serialized-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, SupportsArtifacts: true}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.SerializedValidator, error) {
|
||||
probe.observe(request)
|
||||
return typedTestSerializedValidator{key: "serialized-validator"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
builders := []struct {
|
||||
name string
|
||||
call func() error
|
||||
}{
|
||||
{name: "input", call: func() error { _, err := inputs.BuildWithRequest("input", request); return err }},
|
||||
{name: "chunk", call: func() error { _, err := chunkers.BuildWithRequest("chunk", request); return err }},
|
||||
{name: "output", call: func() error { _, err := outputs.BuildWithRequest("output", request); return err }},
|
||||
{name: "extract", call: func() error {
|
||||
entry, _ := extractors.typedEntry("extract")
|
||||
_, err := entry.builder(request)
|
||||
return err
|
||||
}},
|
||||
{name: "merge", call: func() error {
|
||||
entry, _ := mergers.typedEntry("merge", "test/notes")
|
||||
_, err := entry.builder(request)
|
||||
return err
|
||||
}},
|
||||
{name: "normalize", call: func() error {
|
||||
entry, _ := normalizers.typedEntry("normalize", "test/notes")
|
||||
_, err := entry.builder(request)
|
||||
return err
|
||||
}},
|
||||
{name: "chunk validator", call: func() error {
|
||||
entry, _ := validators.chunkEntry("chunk-validator")
|
||||
_, err := entry.builder(request)
|
||||
return err
|
||||
}},
|
||||
{name: "typed validator", call: func() error {
|
||||
entry, _ := validators.typedEntry("typed-validator", "test/notes")
|
||||
_, err := entry.builder(request)
|
||||
return err
|
||||
}},
|
||||
{name: "serialized validator", call: func() error {
|
||||
entry, _ := validators.serializedEntry("serialized-validator")
|
||||
_, err := entry.builder(request)
|
||||
return err
|
||||
}},
|
||||
}
|
||||
for _, builder := range builders {
|
||||
t.Run(builder.name, func(t *testing.T) {
|
||||
if err := builder.call(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !reflect.DeepEqual(request, probe.want) {
|
||||
t.Fatalf("build request = %#v, want unchanged %#v", request, probe.want)
|
||||
}
|
||||
}
|
||||
|
||||
type buildRequestMutationProbe struct {
|
||||
t *testing.T
|
||||
want BuildRequest
|
||||
}
|
||||
|
||||
func (probe buildRequestMutationProbe) observe(request BuildRequest) {
|
||||
probe.t.Helper()
|
||||
if !reflect.DeepEqual(request, probe.want) {
|
||||
probe.t.Fatalf("builder request = %#v, want independently owned %#v", request, probe.want)
|
||||
}
|
||||
options := request.Options["nested"].(map[string]any)
|
||||
options["value"] = "mutated"
|
||||
request.Options["items"].([]any)[0].(map[string]any)["value"] = "mutated"
|
||||
request.Options["bytes"].([]byte)[0] = 'x'
|
||||
slot := request.References.Slots["reference"]
|
||||
slot.Items[0].Content[0] = 'x'
|
||||
slot.Items = append(slot.Items, contracts.ReferenceItem{SlotName: "reference", Content: []byte("extra")})
|
||||
request.References.Slots["reference"] = slot
|
||||
delete(request.References.Slots, "unused")
|
||||
}
|
||||
|
||||
func constructionBuildRequest() BuildRequest {
|
||||
return BuildRequest{
|
||||
Options: map[string]any{
|
||||
"nested": map[string]any{"value": "original"},
|
||||
"items": []any{map[string]any{"value": "original"}},
|
||||
"bytes": []byte("original"),
|
||||
},
|
||||
References: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"reference": {Slot: contracts.ReferenceSlot{Name: "reference"}, Items: []contracts.ReferenceItem{{SlotName: "reference", Content: []byte("original")}}},
|
||||
"unused": {Slot: contracts.ReferenceSlot{Name: "unused"}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareFailuresOccurBeforeInputParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -100,7 +100,7 @@ func Prepare(resolved ResolvedPipeline, registries Registries, deps ModuleDepend
|
||||
artifactCodecs: registries.ArtifactCodecs,
|
||||
}
|
||||
request := func(binding ModuleBinding, references contracts.ReferenceSet) BuildRequest {
|
||||
return BuildRequest{Dependencies: deps, Options: cloneOptions(binding.Options), References: references}
|
||||
return BuildRequest{Dependencies: deps, Options: binding.Options, References: references}
|
||||
}
|
||||
|
||||
input, err := registries.Inputs.BuildWithRequest(stable.Input.Module, request(stable.Input, contracts.ReferenceSet{}))
|
||||
@@ -202,7 +202,7 @@ func prepareEvidencePlan(resolved ResolvedPipeline, registries Registries, outpu
|
||||
func prepareLane(pipeline ResolvedPipeline, lane ResolvedArtifactLane, registries Registries, deps ModuleDependencies) (preparedLaneExecutor, error) {
|
||||
executor := preparedLaneExecutor{resolved: cloneResolvedArtifactLane(lane)}
|
||||
request := func(binding ModuleBinding, references contracts.ReferenceSet) BuildRequest {
|
||||
return BuildRequest{Dependencies: deps, Options: cloneOptions(binding.Options), References: references}
|
||||
return BuildRequest{Dependencies: deps, Options: binding.Options, References: references}
|
||||
}
|
||||
extractEntry, ok := registries.Extractors.typedEntry(lane.Extract.Module)
|
||||
if !ok {
|
||||
@@ -259,7 +259,7 @@ func prepareValidatorChain(pipeline ResolvedPipeline, registries Registries, dep
|
||||
resolved := resolvedValidatorChain(stage, laneID, moduleKey, pipeline.ValidatorChains)
|
||||
prepared := preparedValidatorChain{resolved: resolved}
|
||||
for _, validator := range resolved.Validators {
|
||||
request := BuildRequest{Dependencies: deps, Options: cloneOptions(validator.Binding.Options), References: references}
|
||||
request := BuildRequest{Dependencies: deps, Options: validator.Binding.Options, References: references}
|
||||
built, err := buildPreparedValidator(registries.Validators, validator, request)
|
||||
if err != nil {
|
||||
return preparedValidatorChain{}, constructionError(pipeline.ID, laneID, stage, moduleKey, validator.Binding.Module, err)
|
||||
@@ -280,7 +280,7 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida
|
||||
if !ok {
|
||||
return preparedValidator{}, fmt.Errorf("typed construction entry is not registered")
|
||||
}
|
||||
implementation, err = entry.builder(cloneBuildRequest(request))
|
||||
implementation, err = entry.builder(request)
|
||||
prepared.typed = implementation
|
||||
prepared.typedValidate = entry.validate
|
||||
case ValidatorTargetChunk:
|
||||
@@ -288,14 +288,14 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida
|
||||
if !ok {
|
||||
return preparedValidator{}, fmt.Errorf("chunk construction entry is not registered")
|
||||
}
|
||||
prepared.chunk, err = entry.builder(cloneBuildRequest(request))
|
||||
prepared.chunk, err = entry.builder(request)
|
||||
implementation = prepared.chunk
|
||||
case ValidatorTargetSerialized:
|
||||
entry, ok := registry.serializedEntry(key)
|
||||
if !ok {
|
||||
return preparedValidator{}, fmt.Errorf("serialized construction entry is not registered")
|
||||
}
|
||||
prepared.serialized, err = entry.builder(cloneBuildRequest(request))
|
||||
prepared.serialized, err = entry.builder(request)
|
||||
implementation = prepared.serialized
|
||||
default:
|
||||
return preparedValidator{}, fmt.Errorf("validator construction target %q is not supported", resolved.Target)
|
||||
@@ -323,7 +323,7 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida
|
||||
}
|
||||
|
||||
func buildErasedModule(builder func(BuildRequest) (any, error), request BuildRequest, key, kind string) (any, error) {
|
||||
implementation, err := builder(cloneBuildRequest(request))
|
||||
implementation, err := builder(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -305,7 +305,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
|
||||
return ResolvedPipeline{}, err
|
||||
}
|
||||
|
||||
input := resolveBinding(profile.Input, "")
|
||||
input, err := resolveBinding(profile.Input, "", fmt.Sprintf("pipeline %q input reference slot", pipelineID))
|
||||
if err != nil {
|
||||
return ResolvedPipeline{}, err
|
||||
}
|
||||
if input.Module == "" {
|
||||
return ResolvedPipeline{}, fmt.Errorf("pipeline %q input module must not be empty", pipelineID)
|
||||
}
|
||||
@@ -320,7 +323,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
|
||||
}
|
||||
capabilities.add(inputModuleSpec.Provides...)
|
||||
|
||||
chunk := resolveBinding(profile.Chunk, DefaultChunkModule)
|
||||
chunk, err := resolveBinding(profile.Chunk, DefaultChunkModule, fmt.Sprintf("pipeline %q chunk reference slot", pipelineID))
|
||||
if err != nil {
|
||||
return ResolvedPipeline{}, err
|
||||
}
|
||||
chunkSpec, err := chunkerSpec(catalog, chunk.Module)
|
||||
if err != nil {
|
||||
return ResolvedPipeline{}, moduleLookupError(pipelineID, "", StageChunk, chunk.Module, err)
|
||||
@@ -363,6 +369,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
|
||||
if err != nil {
|
||||
return ResolvedPipeline{}, err
|
||||
}
|
||||
output, err := resolveBinding(profile.Output, DefaultOutputModule, fmt.Sprintf("pipeline %q output reference slot", pipelineID))
|
||||
if err != nil {
|
||||
return ResolvedPipeline{}, err
|
||||
}
|
||||
resolved := ResolvedPipeline{
|
||||
ID: pipelineID,
|
||||
Input: input,
|
||||
@@ -370,7 +380,7 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
|
||||
Chunk: chunk,
|
||||
ChunkExecutionClass: chunkSpec.ExecutionClass,
|
||||
ChunkReferences: referenceTarget(StageChunk, "", chunk.Module, chunkReferences),
|
||||
Output: resolveBinding(profile.Output, DefaultOutputModule),
|
||||
Output: output,
|
||||
}
|
||||
chunkValidatorChain, err := resolveValidatorChain(pipelineID, "", StageChunk, chunk.Module, chunk.Validators, "", nil, catalog)
|
||||
if err != nil {
|
||||
@@ -485,13 +495,29 @@ func resolveArtifactLane(
|
||||
inherited capabilitySet,
|
||||
catalog ModuleCatalog,
|
||||
) (ResolvedArtifactLane, []ResolvedValidatorChain, capabilitySet, error) {
|
||||
extract, err := resolveBinding(profile.Extract, "", fmt.Sprintf("pipeline %q step %q lane %q extract reference slot", pipelineID, stepID, laneID))
|
||||
if err != nil {
|
||||
return ResolvedArtifactLane{}, nil, nil, err
|
||||
}
|
||||
merge, err := resolveBinding(profile.Merge, DefaultMergeModule, fmt.Sprintf("pipeline %q step %q lane %q merge reference slot", pipelineID, stepID, laneID))
|
||||
if err != nil {
|
||||
return ResolvedArtifactLane{}, nil, nil, err
|
||||
}
|
||||
normalize, err := resolveBinding(profile.Normalize, DefaultNormalizeModule, fmt.Sprintf("pipeline %q step %q lane %q normalize reference slot", pipelineID, stepID, laneID))
|
||||
if err != nil {
|
||||
return ResolvedArtifactLane{}, nil, nil, err
|
||||
}
|
||||
validators, err := resolveBindings(profile.Validators, "", fmt.Sprintf("pipeline %q step %q lane %q validator reference slot", pipelineID, stepID, laneID))
|
||||
if err != nil {
|
||||
return ResolvedArtifactLane{}, nil, nil, err
|
||||
}
|
||||
lane := ResolvedArtifactLane{
|
||||
StepID: strings.TrimSpace(stepID),
|
||||
ID: laneID,
|
||||
Extract: resolveBinding(profile.Extract, ""),
|
||||
Merge: resolveBinding(profile.Merge, DefaultMergeModule),
|
||||
Normalize: resolveBinding(profile.Normalize, DefaultNormalizeModule),
|
||||
Validators: resolveBindings(profile.Validators, ""),
|
||||
Extract: extract,
|
||||
Merge: merge,
|
||||
Normalize: normalize,
|
||||
Validators: validators,
|
||||
}
|
||||
if lane.Extract.Module == "" {
|
||||
return ResolvedArtifactLane{}, nil, nil, fmt.Errorf("pipeline %q lane %q extract module must not be empty", pipelineID, laneID)
|
||||
@@ -516,6 +542,7 @@ func resolveArtifactLane(
|
||||
extractReferences := mergeReferenceMaps(profile.References, lane.Extract.References)
|
||||
references, err := resolveReferenceTargetBindings(referenceResolutionTarget{
|
||||
PipelineID: pipelineID,
|
||||
StepID: strings.TrimSpace(stepID),
|
||||
LaneID: laneID,
|
||||
Stage: StageExtract,
|
||||
Module: lane.Extract.Module,
|
||||
@@ -541,6 +568,7 @@ func resolveArtifactLane(
|
||||
}
|
||||
mergeReferences, err := resolveReferenceTargetBindings(referenceResolutionTarget{
|
||||
PipelineID: pipelineID,
|
||||
StepID: strings.TrimSpace(stepID),
|
||||
LaneID: laneID,
|
||||
Stage: StageMerge,
|
||||
Module: lane.Merge.Module,
|
||||
@@ -566,6 +594,7 @@ func resolveArtifactLane(
|
||||
}
|
||||
normalizeReferences, err := resolveReferenceTargetBindings(referenceResolutionTarget{
|
||||
PipelineID: pipelineID,
|
||||
StepID: strings.TrimSpace(stepID),
|
||||
LaneID: laneID,
|
||||
Stage: StageNormalize,
|
||||
Module: lane.Normalize.Module,
|
||||
@@ -925,7 +954,7 @@ func validatePipelineReferenceDefaults(
|
||||
lanesByID map[string]ArtifactLaneProfile,
|
||||
catalog ModuleCatalog,
|
||||
) error {
|
||||
normalizedPipelineReferences, err := normalizedReferenceMap(pipelineReferences, fmt.Sprintf("pipeline %q reference slot", pipelineID))
|
||||
normalizedPipelineReferences, err := normalizeReferenceMap(pipelineReferences, fmt.Sprintf("pipeline %q reference slot", pipelineID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -939,38 +968,38 @@ func validatePipelineReferenceDefaults(
|
||||
}
|
||||
for _, laneID := range sortedArtifactLaneProfileKeys(lanesByID) {
|
||||
laneProfile := lanesByID[laneID]
|
||||
extract := resolveBinding(laneProfile.Extract, "")
|
||||
if extract.Module == "" {
|
||||
extractModule := resolveModuleKey(laneProfile.Extract.Module, "")
|
||||
if extractModule == "" {
|
||||
return fmt.Errorf("pipeline %q lane %q extract module must not be empty", pipelineID, laneID)
|
||||
}
|
||||
extractSpec, err := extractorSpec(catalog, extract.Module)
|
||||
extractSpec, err := extractorSpec(catalog, extractModule)
|
||||
if err != nil {
|
||||
return moduleLookupError(pipelineID, laneID, StageExtract, extract.Module, err)
|
||||
return moduleLookupError(pipelineID, laneID, StageExtract, extractModule, err)
|
||||
}
|
||||
for _, slot := range extractSpec.ReferenceSlots {
|
||||
declaredByAnyTarget[slot.Name] = struct{}{}
|
||||
}
|
||||
|
||||
merge := resolveBinding(laneProfile.Merge, DefaultMergeModule)
|
||||
mergeModule := resolveModuleKey(laneProfile.Merge.Module, DefaultMergeModule)
|
||||
var artifactType reflect.Type
|
||||
artifactKind := extractSpec.ArtifactKind
|
||||
if artifactKind != "" && catalog.Extractors != nil {
|
||||
if entry, ok := catalog.Extractors.typedEntry(extract.Module); ok {
|
||||
if entry, ok := catalog.Extractors.typedEntry(extractModule); ok {
|
||||
artifactType = entry.valueType
|
||||
}
|
||||
}
|
||||
mergeSpec, err := mergerSpecForArtifact(catalog, merge.Module, artifactKind, artifactType)
|
||||
mergeSpec, err := mergerSpecForArtifact(catalog, mergeModule, artifactKind, artifactType)
|
||||
if err != nil {
|
||||
return moduleLookupError(pipelineID, laneID, StageMerge, merge.Module, err)
|
||||
return moduleLookupError(pipelineID, laneID, StageMerge, mergeModule, err)
|
||||
}
|
||||
for _, slot := range mergeSpec.ReferenceSlots {
|
||||
declaredByAnyTarget[slot.Name] = struct{}{}
|
||||
}
|
||||
|
||||
normalize := resolveBinding(laneProfile.Normalize, DefaultNormalizeModule)
|
||||
normalizeSpec, err := normalizerSpecForArtifact(catalog, normalize.Module, artifactKind, artifactType)
|
||||
normalizeModule := resolveModuleKey(laneProfile.Normalize.Module, DefaultNormalizeModule)
|
||||
normalizeSpec, err := normalizerSpecForArtifact(catalog, normalizeModule, artifactKind, artifactType)
|
||||
if err != nil {
|
||||
return moduleLookupError(pipelineID, laneID, StageNormalize, normalize.Module, err)
|
||||
return moduleLookupError(pipelineID, laneID, StageNormalize, normalizeModule, err)
|
||||
}
|
||||
for _, slot := range normalizeSpec.ReferenceSlots {
|
||||
declaredByAnyTarget[slot.Name] = struct{}{}
|
||||
@@ -987,6 +1016,7 @@ func validatePipelineReferenceDefaults(
|
||||
|
||||
type referenceResolutionTarget struct {
|
||||
PipelineID string
|
||||
StepID string
|
||||
LaneID string
|
||||
Stage ModuleStage
|
||||
Module string
|
||||
@@ -1046,7 +1076,7 @@ func resolveReferenceTargetBindings(target referenceResolutionTarget) ([]Referen
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedPipelineReferences, err := normalizedReferenceMap(target.PipelineReferences, fmt.Sprintf("pipeline %q reference slot", target.PipelineID))
|
||||
normalizedPipelineReferences, err := normalizeReferenceMap(target.PipelineReferences, fmt.Sprintf("pipeline %q reference slot", target.PipelineID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1059,7 +1089,7 @@ func resolveReferenceTargetBindings(target referenceResolutionTarget) ([]Referen
|
||||
}
|
||||
}
|
||||
|
||||
normalizedLocalReferences, err := normalizedReferenceMap(target.LocalReferences, referenceTargetSlotLabel(target))
|
||||
normalizedLocalReferences, err := normalizeReferenceMap(target.LocalReferences, referenceTargetSlotLabel(target))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1166,6 +1196,9 @@ func sortedReferenceBindings(bindings map[string]ReferenceBinding) []ReferenceBi
|
||||
|
||||
func referenceTargetErrorContext(target referenceResolutionTarget) string {
|
||||
if target.LaneID != "" {
|
||||
if target.StepID != "" {
|
||||
return fmt.Sprintf("pipeline %q step %q lane %q %s module %q", target.PipelineID, target.StepID, target.LaneID, target.Stage, target.Module)
|
||||
}
|
||||
return fmt.Sprintf("pipeline %q lane %q %s module %q", target.PipelineID, target.LaneID, target.Stage, target.Module)
|
||||
}
|
||||
return fmt.Sprintf("pipeline %q %s module %q", target.PipelineID, target.Stage, target.Module)
|
||||
@@ -1173,17 +1206,22 @@ func referenceTargetErrorContext(target referenceResolutionTarget) string {
|
||||
|
||||
func referenceTargetSlotLabel(target referenceResolutionTarget) string {
|
||||
if target.LaneID != "" {
|
||||
if target.StepID != "" {
|
||||
return fmt.Sprintf("pipeline %q step %q lane %q %s reference slot", target.PipelineID, target.StepID, target.LaneID, target.Stage)
|
||||
}
|
||||
return fmt.Sprintf("pipeline %q lane %q %s reference slot", target.PipelineID, target.LaneID, target.Stage)
|
||||
}
|
||||
return fmt.Sprintf("pipeline %q %s reference slot", target.PipelineID, target.Stage)
|
||||
}
|
||||
|
||||
func normalizedReferenceMap(values map[string]ReferenceSource, keyName string) (map[string]ReferenceSource, error) {
|
||||
func normalizeReferenceMap(values map[string]ReferenceSource, keyName string) (map[string]ReferenceSource, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make(map[string]ReferenceSource, len(values))
|
||||
for rawSlotName, rawSource := range values {
|
||||
rawSlotNames := sortedStringMapKeys(values)
|
||||
for _, rawSlotName := range rawSlotNames {
|
||||
rawSource := values[rawSlotName]
|
||||
slotName := strings.TrimSpace(rawSlotName)
|
||||
if slotName == "" {
|
||||
return nil, fmt.Errorf("%s must not be empty", keyName)
|
||||
@@ -1236,20 +1274,29 @@ func sortedReferenceBindingKeys(values map[string]ReferenceBinding) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
func resolveBinding(binding ModuleBinding, defaultModule string) ModuleBinding {
|
||||
module := strings.TrimSpace(binding.Module)
|
||||
if module == "" {
|
||||
module = defaultModule
|
||||
}
|
||||
func resolveBinding(binding ModuleBinding, defaultModule string, referenceSlotLabel string) (ModuleBinding, error) {
|
||||
module := resolveModuleKey(binding.Module, defaultModule)
|
||||
llmProfile := strings.TrimSpace(binding.LLMProfile)
|
||||
references, err := normalizeReferenceMap(binding.References, referenceSlotLabel)
|
||||
if err != nil {
|
||||
return ModuleBinding{}, err
|
||||
}
|
||||
return ModuleBinding{
|
||||
Module: module,
|
||||
LLMProfile: llmProfile,
|
||||
Retries: binding.Retries,
|
||||
Options: cloneOptions(binding.Options),
|
||||
References: normalizeReferenceMap(binding.References),
|
||||
References: references,
|
||||
Validators: cloneValidatorOverride(binding.Validators),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveModuleKey(module string, defaultModule string) string {
|
||||
module = strings.TrimSpace(module)
|
||||
if module == "" {
|
||||
return defaultModule
|
||||
}
|
||||
return module
|
||||
}
|
||||
|
||||
func applyEffectiveLLMProfiles(resolved *ResolvedPipeline, pipelineProfile, overrideProfile string) error {
|
||||
@@ -1312,17 +1359,20 @@ func applyEffectiveLLMProfiles(resolved *ResolvedPipeline, pipelineProfile, over
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveBindings(bindings []ModuleBinding, defaultModule string) []ModuleBinding {
|
||||
func resolveBindings(bindings []ModuleBinding, defaultModule string, referenceSlotLabel string) ([]ModuleBinding, error) {
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
resolved := make([]ModuleBinding, 0, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
resolvedBinding := resolveBinding(binding, defaultModule)
|
||||
for index, binding := range bindings {
|
||||
resolvedBinding, err := resolveBinding(binding, defaultModule, fmt.Sprintf("%s %d", referenceSlotLabel, index))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved = append(resolved, resolvedBinding)
|
||||
}
|
||||
return resolved
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func cloneOptions(options map[string]any) map[string]any {
|
||||
@@ -1358,25 +1408,6 @@ func cloneOptionValue(value any) any {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeReferenceMap(values map[string]ReferenceSource) map[string]ReferenceSource {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]ReferenceSource, len(values))
|
||||
keys := make([]string, 0, len(values))
|
||||
rawByNormalized := make(map[string]string, len(values))
|
||||
for rawKey := range values {
|
||||
key := strings.TrimSpace(rawKey)
|
||||
rawByNormalized[key] = rawKey
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
out[key] = cloneReferenceSource(values[rawByNormalized[key]])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func selectedArtifactLanes(pipelineID string, artifacts map[string]ArtifactLaneProfile, options ResolveOptions) (map[string]ArtifactLaneProfile, []string, error) {
|
||||
lanesByID := make(map[string]ArtifactLaneProfile, len(artifacts))
|
||||
for rawLaneID, lane := range artifacts {
|
||||
|
||||
@@ -159,6 +159,85 @@ func TestResolvePipelineAppliesDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePipelineRejectsReferenceSlotCollisionsAfterTrimming(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
configure func(*PipelineProfile)
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "chunk",
|
||||
configure: func(profile *PipelineProfile) {
|
||||
profile.Chunk = ModuleBinding{Module: DefaultChunkModule, References: map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}}
|
||||
},
|
||||
want: []string{`pipeline "references"`, "chunk", `reference slot "rules" is duplicated after trimming`},
|
||||
},
|
||||
{
|
||||
name: "extract",
|
||||
configure: func(profile *PipelineProfile) {
|
||||
lane := profile.Artifacts["lane"]
|
||||
lane.Extract.References = map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}
|
||||
profile.Artifacts["lane"] = lane
|
||||
},
|
||||
want: []string{`pipeline "references"`, `step "default"`, `lane "lane"`, "extract", `reference slot "rules" is duplicated after trimming`},
|
||||
},
|
||||
{
|
||||
name: "merge",
|
||||
configure: func(profile *PipelineProfile) {
|
||||
lane := profile.Artifacts["lane"]
|
||||
lane.Merge = ModuleBinding{Module: DefaultMergeModule, References: map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}}
|
||||
profile.Artifacts["lane"] = lane
|
||||
},
|
||||
want: []string{`pipeline "references"`, `step "default"`, `lane "lane"`, "merge", `reference slot "rules" is duplicated after trimming`},
|
||||
},
|
||||
{
|
||||
name: "normalize",
|
||||
configure: func(profile *PipelineProfile) {
|
||||
lane := profile.Artifacts["lane"]
|
||||
lane.Normalize = ModuleBinding{Module: DefaultNormalizeModule, References: map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}}
|
||||
profile.Artifacts["lane"] = lane
|
||||
},
|
||||
want: []string{`pipeline "references"`, `step "default"`, `lane "lane"`, "normalize", `reference slot "rules" is duplicated after trimming`},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := PipelineProfile{
|
||||
ID: "references",
|
||||
Input: Binding("text"),
|
||||
Artifacts: map[string]ArtifactLaneProfile{
|
||||
"lane": {Extract: Binding("event-extractor")},
|
||||
},
|
||||
}
|
||||
tt.configure(&profile)
|
||||
_, err := ResolvePipeline(profile, ResolveOptions{}, newProfileCatalog(t))
|
||||
if err == nil {
|
||||
t.Fatal("ResolvePipeline() error = nil, want normalized reference collision")
|
||||
}
|
||||
for _, fragment := range tt.want {
|
||||
if !strings.Contains(err.Error(), fragment) {
|
||||
t.Fatalf("ResolvePipeline() error = %q, want context %q", err, fragment)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePipelineRejectsEmptyReferenceSlotAfterTrimming(t *testing.T) {
|
||||
_, err := ResolvePipeline(PipelineProfile{
|
||||
ID: "references",
|
||||
Input: Binding("text"),
|
||||
Chunk: ModuleBinding{Module: DefaultChunkModule, References: map[string]ReferenceSource{
|
||||
" \t ": ExternalReference("rules.md"),
|
||||
}},
|
||||
Artifacts: map[string]ArtifactLaneProfile{
|
||||
"lane": {Extract: Binding("event-extractor")},
|
||||
},
|
||||
}, ResolveOptions{}, newProfileCatalog(t))
|
||||
if err == nil || !strings.Contains(err.Error(), `pipeline "references" chunk reference slot must not be empty`) {
|
||||
t.Fatalf("ResolvePipeline() error = %v, want contextual empty reference-slot rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePipelineAppliesEffectiveLLMProfiles(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
|
||||
@@ -3,7 +3,10 @@ package pipeline
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"mime"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -124,10 +127,30 @@ func materializeReferenceTarget(
|
||||
if err != nil {
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q: %w", referenceTargetContext(pipelineID, target), slotName, binding.Source, err)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q read %q: %w", referenceTargetContext(pipelineID, target), slotName, path, err)
|
||||
}
|
||||
regularFileSize := int64(0)
|
||||
if info, statErr := file.Stat(); statErr == nil && info.Mode().IsRegular() {
|
||||
regularFileSize = info.Size()
|
||||
}
|
||||
content, err := readReferenceContent(file, slot.MaxBytes)
|
||||
closeErr := file.Close()
|
||||
if err == nil && closeErr != nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
var sizeErr *referenceSizeLimitError
|
||||
if errors.As(err, &sizeErr) {
|
||||
sizeBytes := sizeErr.SizeBytes
|
||||
if regularFileSize > sizeBytes {
|
||||
sizeBytes = regularFileSize
|
||||
}
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q is %d bytes, limit %d", referenceTargetContext(pipelineID, target), slotName, path, sizeBytes, slot.MaxBytes)
|
||||
}
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q read %q: %w", referenceTargetContext(pipelineID, target), slotName, path, err)
|
||||
}
|
||||
if !utf8.Valid(content) {
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q must be UTF-8 text", referenceTargetContext(pipelineID, target), slotName, path)
|
||||
}
|
||||
@@ -135,9 +158,6 @@ func materializeReferenceTarget(
|
||||
if !referenceMediaTypeAccepted(mediaType, slot.AcceptedMediaTypes) {
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q media type %q is not accepted", referenceTargetContext(pipelineID, target), slotName, path, mediaType)
|
||||
}
|
||||
if slot.MaxBytes > 0 && int64(len(content)) > slot.MaxBytes {
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q is %d bytes, limit %d", referenceTargetContext(pipelineID, target), slotName, path, len(content), slot.MaxBytes)
|
||||
}
|
||||
if len(content) == 0 {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: referenceWarningScope(pipelineID, target, slotName),
|
||||
@@ -163,6 +183,32 @@ func materializeReferenceTarget(
|
||||
return set, warnings, nil
|
||||
}
|
||||
|
||||
type referenceSizeLimitError struct {
|
||||
SizeBytes int64
|
||||
MaxBytes int64
|
||||
}
|
||||
|
||||
func (err *referenceSizeLimitError) Error() string {
|
||||
return fmt.Sprintf("reference is %d bytes, limit %d", err.SizeBytes, err.MaxBytes)
|
||||
}
|
||||
|
||||
func readReferenceContent(reader io.Reader, maxBytes int64) ([]byte, error) {
|
||||
if maxBytes <= 0 {
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
if maxBytes == math.MaxInt64 {
|
||||
return nil, fmt.Errorf("maximum reference size %d cannot be safely bounded", maxBytes)
|
||||
}
|
||||
content, err := io.ReadAll(io.LimitReader(reader, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(content)) > maxBytes {
|
||||
return nil, &referenceSizeLimitError{SizeBytes: int64(len(content)), MaxBytes: maxBytes}
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func referenceTargetSpec(target ResolvedReferenceTarget, artifactKind contracts.ArtifactKind, catalog ModuleCatalog) (ModuleSpec, error) {
|
||||
switch target.Stage {
|
||||
case StageChunk:
|
||||
|
||||
@@ -2,6 +2,9 @@ package pipeline
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -12,6 +15,61 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
type countingReader struct {
|
||||
content []byte
|
||||
consumed int
|
||||
}
|
||||
|
||||
func (reader *countingReader) Read(buffer []byte) (int, error) {
|
||||
if len(reader.content) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
read := copy(buffer, reader.content)
|
||||
reader.content = reader.content[read:]
|
||||
reader.consumed += read
|
||||
return read, nil
|
||||
}
|
||||
|
||||
func TestReadReferenceContentBoundsPositiveMaximum(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
maxBytes int64
|
||||
wantContent string
|
||||
wantConsumed int
|
||||
wantLimitErr bool
|
||||
wantReadError bool
|
||||
}{
|
||||
{name: "exact maximum", content: "abc", maxBytes: 3, wantContent: "abc", wantConsumed: 3},
|
||||
{name: "oversized", content: "abcdef", maxBytes: 3, wantConsumed: 4, wantLimitErr: true},
|
||||
{name: "unbounded", content: "abcdef", maxBytes: 0, wantContent: "abcdef", wantConsumed: 6},
|
||||
{name: "negative maximum remains unbounded", content: "abcdef", maxBytes: -1, wantContent: "abcdef", wantConsumed: 6},
|
||||
{name: "overflowing maximum", content: "abcdef", maxBytes: math.MaxInt64, wantConsumed: 0, wantReadError: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reader := &countingReader{content: []byte(tt.content)}
|
||||
content, err := readReferenceContent(reader, tt.maxBytes)
|
||||
if reader.consumed != tt.wantConsumed {
|
||||
t.Fatalf("reader consumed %d bytes, want %d", reader.consumed, tt.wantConsumed)
|
||||
}
|
||||
if got := string(content); got != tt.wantContent {
|
||||
t.Fatalf("content = %q, want %q", got, tt.wantContent)
|
||||
}
|
||||
var sizeErr *referenceSizeLimitError
|
||||
if errors.As(err, &sizeErr) != tt.wantLimitErr {
|
||||
t.Fatalf("error = %v, want limit error=%t", err, tt.wantLimitErr)
|
||||
}
|
||||
if (err != nil) != (tt.wantLimitErr || tt.wantReadError) {
|
||||
t.Fatalf("error = %v, want error=%t", err, tt.wantLimitErr || tt.wantReadError)
|
||||
}
|
||||
if sizeErr != nil && (sizeErr.SizeBytes != 4 || sizeErr.MaxBytes != 3) {
|
||||
t.Fatalf("size error = %#v, want observed size 4 and limit 3", sizeErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeReferencesResolvesPathsAndDigestsContent(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
workingDir := t.TempDir()
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
@@ -80,6 +81,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
if r == nil {
|
||||
return output, fmt.Errorf("runner must not be nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return output, err
|
||||
}
|
||||
if err := validateRunInput(input); err != nil {
|
||||
return output, err
|
||||
}
|
||||
@@ -164,6 +168,10 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
if metadataErr != nil {
|
||||
return failOutput(output), fmt.Errorf("clone input adapter metadata: %w", metadataErr)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
_ = checkpoints.SourceFailed(adapter.Key(), err)
|
||||
return failOutput(output), err
|
||||
}
|
||||
doc, err = adapter.Parse(ctx, contracts.ParseRequest{
|
||||
SourceID: input.SourceID,
|
||||
Path: input.Path,
|
||||
@@ -171,6 +179,10 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
LLMProfile: input.pipeline.Input.LLMProfile,
|
||||
Metadata: requestMetadata,
|
||||
})
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
_ = checkpoints.SourceFailed(adapter.Key(), ctxErr)
|
||||
return failOutput(output), ctxErr
|
||||
}
|
||||
if err != nil {
|
||||
_ = checkpoints.SourceFailed(adapter.Key(), err)
|
||||
return failOutput(output), fmt.Errorf("parse input with adapter %q: %w", adapter.Key(), err)
|
||||
@@ -228,9 +240,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
if chunkResult.rejection != nil {
|
||||
output.Rejected = append(output.Rejected, *chunkResult.rejection)
|
||||
}
|
||||
if chunkResult.accepted || chunkResult.lookup.Status == ChunkPlanHit {
|
||||
output.Warnings = append(output.Warnings, chunkResult.warnings...)
|
||||
}
|
||||
output.Warnings = append(output.Warnings, chunkResult.warnings...)
|
||||
chunkDebugPayload := map[string]any{
|
||||
"cache_mode": chunkMode,
|
||||
"lookup": chunkResult.lookup,
|
||||
@@ -280,6 +290,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
return failOutput(output), err
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
|
||||
if len(output.Rejected) > 0 {
|
||||
output.Manifest.ValidationStatus = "rejected"
|
||||
@@ -293,11 +306,17 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
if err := attachModuleManifestMetadata(&output, "output", encoder); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
outputStarted := time.Now().UTC()
|
||||
evidenceArtifact, evidenceSummary, err := buildOutputEvidenceContext(input.Prepared, doc, output.NormalizeOutputs)
|
||||
if err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
if evidenceSummary != nil {
|
||||
if err := writeDebugTimed(debugRecorder, "output/evidence-context.json", debugTimedEnvelope{
|
||||
Stage: string(StageOutput),
|
||||
@@ -307,6 +326,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
}); err != nil {
|
||||
return failOutput(output), fmt.Errorf("write evidence context debug artifact: %w", err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
}
|
||||
outputDebugPayload := map[string]any{
|
||||
"manifest": output.Manifest,
|
||||
@@ -327,10 +349,16 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
}); err != nil {
|
||||
return failOutput(output), fmt.Errorf("write output debug artifact: %w", err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
outputMetadata, err := cloneMetadata(input.Metadata)
|
||||
if err != nil {
|
||||
return failOutput(output), fmt.Errorf("clone output encoder metadata: %w", err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
encoded, err := encoder.Encode(ctx, contracts.OutputRequest{
|
||||
Manifest: output.Manifest,
|
||||
NormalizeOutputs: cloneSerializedOutputs(output.NormalizeOutputs),
|
||||
@@ -341,7 +369,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
ChunkMap: contracts.CloneSerializedArtifactPointer(acceptedChunkMap),
|
||||
EvidenceContext: contracts.CloneSerializedArtifactPointer(evidenceArtifact),
|
||||
})
|
||||
output.Warnings = append(output.Warnings, encoded.Warnings...)
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return failOutput(output), ctxErr
|
||||
}
|
||||
if err != nil {
|
||||
return failOutput(output), fmt.Errorf("encode output with encoder %q: %w", encoder.Key(), err)
|
||||
}
|
||||
@@ -349,7 +379,6 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
if err != nil {
|
||||
return failOutput(output), fmt.Errorf("validate output files from encoder %q: %w", encoder.Key(), err)
|
||||
}
|
||||
output.OutputFiles = files
|
||||
if err := writeDebugTimed(debugRecorder, "output/output.json", debugTimedEnvelope{
|
||||
Stage: string(StageOutput),
|
||||
ModuleKey: encoder.Key(),
|
||||
@@ -361,6 +390,11 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
}); err != nil {
|
||||
return failOutput(output), fmt.Errorf("write output debug artifact: %w", err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
output.Warnings = append(output.Warnings, encoded.Warnings...)
|
||||
output.OutputFiles = files
|
||||
|
||||
return output, nil
|
||||
}
|
||||
@@ -386,45 +420,52 @@ func (r *Runner) runPreparedSteps(ctx context.Context, input RunInput, checkpoin
|
||||
return nil
|
||||
}
|
||||
|
||||
func runWithRetry(ctx context.Context, retries int, run func(attempt int) (bool, *contracts.RejectedOutput, error)) (bool, *contracts.RejectedOutput, error) {
|
||||
type retryAttemptResult struct {
|
||||
accepted bool
|
||||
rejection *contracts.RejectedOutput
|
||||
warnings []contracts.Warning
|
||||
}
|
||||
|
||||
func runWithRetry(ctx context.Context, retries int, run func(attempt int) (retryAttemptResult, error)) (retryAttemptResult, error) {
|
||||
attempts := retries + 1
|
||||
var last *contracts.RejectedOutput
|
||||
var last retryAttemptResult
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, nil, err
|
||||
return retryAttemptResult{}, err
|
||||
}
|
||||
accepted, rejection, err := run(attempt)
|
||||
result, err := run(attempt)
|
||||
if err != nil {
|
||||
var debugErr *attemptDebugPersistenceError
|
||||
if errors.As(err, &debugErr) {
|
||||
return false, nil, fmt.Errorf("failed after %d attempt(s): %w", attempt, err)
|
||||
return retryAttemptResult{}, fmt.Errorf("failed after %d attempt(s): %w", attempt, err)
|
||||
}
|
||||
if attempt == attempts {
|
||||
return false, nil, fmt.Errorf("failed after %d attempt(s): %w", attempt, err)
|
||||
return retryAttemptResult{}, fmt.Errorf("failed after %d attempt(s): %w", attempt, err)
|
||||
}
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return false, nil, ctxErr
|
||||
return retryAttemptResult{}, ctxErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if accepted {
|
||||
return true, nil, nil
|
||||
if result.accepted {
|
||||
return result, nil
|
||||
}
|
||||
if rejection != nil {
|
||||
if result.rejection != nil {
|
||||
rejection := *result.rejection
|
||||
rejection.AttemptCount = attempt
|
||||
last = rejection
|
||||
last = retryAttemptResult{rejection: &rejection, warnings: cloneWarnings(result.warnings)}
|
||||
}
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return false, nil, ctxErr
|
||||
return retryAttemptResult{}, ctxErr
|
||||
}
|
||||
if attempt == attempts {
|
||||
if last == nil {
|
||||
last = &contracts.RejectedOutput{ReasonCode: "output_rejected", Message: "output rejected", AttemptCount: attempt}
|
||||
if last.rejection == nil {
|
||||
last.rejection = &contracts.RejectedOutput{ReasonCode: "output_rejected", Message: "output rejected", AttemptCount: attempt}
|
||||
}
|
||||
return false, last, nil
|
||||
return last, nil
|
||||
}
|
||||
}
|
||||
return false, last, nil
|
||||
return last, nil
|
||||
}
|
||||
|
||||
func (r *Runner) validateChunks(ctx context.Context, doc *source.SourceDocument, moduleKey string, chunks []source.Chunk, sourceInput contracts.LLMInputMaterial, sessionID string, references contracts.ReferenceSet, metadata map[string]any, prepared preparedValidatorChain, attempt int, debug DebugRecorder) ([]contracts.Warning, *contracts.RejectedOutput, error) {
|
||||
@@ -437,7 +478,7 @@ func (r *Runner) validateChunks(ctx context.Context, doc *source.SourceDocument,
|
||||
for index, item := range prepared.validators {
|
||||
binding := item.resolved.Binding
|
||||
started := time.Now().UTC()
|
||||
attemptPath := path.Join("validate", debugPathComponent(string(StageChunk)), "", debugPathComponent(moduleKey), fmt.Sprintf("%02d-%s-attempt-%02d", index+1, debugPathComponent(binding.Module), attempt))
|
||||
attemptPath := path.Join("validate", fileio.EncodePathComponent(string(StageChunk)), "", fileio.EncodePathComponent(moduleKey), fmt.Sprintf("%02d-%s-attempt-%02d", index+1, fileio.EncodePathComponent(binding.Module), attempt))
|
||||
validatorCtx, llmScope := withIsolatedDebugLLMScope(ctx, attemptPath)
|
||||
var result contracts.ValidationResult
|
||||
requestMetadata, cloneErr := cloneMetadata(metadata)
|
||||
|
||||
@@ -351,7 +351,7 @@ func TestRunnerKeepsValidatorLLMCallsOutOfModuleAttempt(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v, want nil", err)
|
||||
}
|
||||
moduleEnvelope := debug.envelope(t, "merge/notes/attempt-01.json")
|
||||
validatorPath := "validate/merge/notes/typed~2fmerge/01-llm-check-attempt-01.json"
|
||||
validatorPath := "validate/merge/notes/typed%2Fmerge/01-llm-check-attempt-01.json"
|
||||
validatorEnvelope := debug.envelope(t, validatorPath)
|
||||
if len(moduleEnvelope.LLMCalls) != 1 || !strings.Contains(moduleEnvelope.LLMCalls[0].ResponsePath, "merge/notes/attempt-01/") {
|
||||
t.Fatalf("module LLM calls = %#v, want module call only", moduleEnvelope.LLMCalls)
|
||||
|
||||
@@ -2,6 +2,7 @@ package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -13,10 +14,12 @@ import (
|
||||
)
|
||||
|
||||
type observedNotesCodec struct {
|
||||
candidateValues []codecNotes
|
||||
finalValues []codecNotes
|
||||
candidateError string
|
||||
finalError string
|
||||
candidateValues []codecNotes
|
||||
candidateDecodedValues []codecNotes
|
||||
finalValues []codecNotes
|
||||
candidateError string
|
||||
candidateDecodeError string
|
||||
finalError string
|
||||
}
|
||||
|
||||
func (*observedNotesCodec) Kind() contracts.ArtifactKind { return "test/notes" }
|
||||
@@ -49,6 +52,18 @@ func (*observedNotesCodec) Decode(content []byte) (codecNotes, error) {
|
||||
return value, json.Unmarshal(content, &value)
|
||||
}
|
||||
|
||||
func (c *observedNotesCodec) DecodeCandidate(content []byte) (codecNotes, error) {
|
||||
value, err := c.Decode(content)
|
||||
if err != nil {
|
||||
return codecNotes{}, err
|
||||
}
|
||||
if c.candidateDecodeError != "" && firstNote(value) == c.candidateDecodeError {
|
||||
return codecNotes{}, errors.New("candidate decoding failed")
|
||||
}
|
||||
c.candidateDecodedValues = append(c.candidateDecodedValues, value)
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func firstNote(value codecNotes) string {
|
||||
if len(value.Items) == 0 {
|
||||
return ""
|
||||
@@ -64,6 +79,10 @@ func (c *observedNotesCodec) finalCalls(value string) int {
|
||||
return matchingNotes(c.finalValues, value)
|
||||
}
|
||||
|
||||
func (c *observedNotesCodec) candidateDecodeCount() int {
|
||||
return len(c.candidateDecodedValues)
|
||||
}
|
||||
|
||||
func matchingNotes(values []codecNotes, value string) int {
|
||||
count := 0
|
||||
for _, candidate := range values {
|
||||
@@ -110,6 +129,16 @@ func installObservedNotesCodec(t *testing.T, prepared *PreparedPipeline, codec *
|
||||
func configureCandidateOperation(prepared *PreparedPipeline, target ModuleStage, value codecNotes) {
|
||||
lane := &prepared.Steps[0].lanes[0]
|
||||
switch target {
|
||||
case StageExtract:
|
||||
lane.typed.extract = func(context.Context, any, contracts.TypedExtractionRequest) (erasedTypedResult, error) {
|
||||
return erasedTypedResult{Value: value}, nil
|
||||
}
|
||||
lane.typed.merge = func(context.Context, any, contracts.TypedMergeRequest[any]) (erasedTypedResult, error) {
|
||||
return erasedTypedResult{Value: codecNotes{Items: []string{"merged-other"}}}, nil
|
||||
}
|
||||
lane.typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
|
||||
return erasedTypedResult{Value: codecNotes{Items: []string{"normalized-other"}}}, nil
|
||||
}
|
||||
case StageMerge:
|
||||
lane.typed.merge = func(context.Context, any, contracts.TypedMergeRequest[any]) (erasedTypedResult, error) {
|
||||
return erasedTypedResult{Value: value}, nil
|
||||
@@ -135,6 +164,8 @@ func setCandidateValidator(prepared *PreparedPipeline, target ModuleStage, appro
|
||||
},
|
||||
}
|
||||
switch target {
|
||||
case StageExtract:
|
||||
prepared.Steps[0].lanes[0].extractValidators.validators = []preparedValidator{validator}
|
||||
case StageMerge:
|
||||
prepared.Steps[0].lanes[0].mergeValidators.validators = []preparedValidator{validator}
|
||||
case StageNormalize:
|
||||
@@ -142,6 +173,135 @@ func setCandidateValidator(prepared *PreparedPipeline, target ModuleStage, appro
|
||||
}
|
||||
}
|
||||
|
||||
type serializedValidationFunc func(context.Context, contracts.SerializedValidationRequest) (contracts.ValidationResult, error)
|
||||
|
||||
func (serializedValidationFunc) Name() string { return "candidate-serialized" }
|
||||
func (serializedValidationFunc) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
}
|
||||
func (validate serializedValidationFunc) Validate(ctx context.Context, request contracts.SerializedValidationRequest) (contracts.ValidationResult, error) {
|
||||
return validate(ctx, request)
|
||||
}
|
||||
|
||||
func TestRunnerIsolatesTypedValidatorCandidates(t *testing.T) {
|
||||
for _, target := range []ModuleStage{StageExtract, StageMerge, StageNormalize} {
|
||||
t.Run(string(target), func(t *testing.T) {
|
||||
prepared := preparedAttemptDebugPipeline(t)
|
||||
prepared.Steps[0].lanes[0].extractValidators = preparedValidatorChain{}
|
||||
prepared.Steps[0].lanes[0].mergeValidators = preparedValidatorChain{}
|
||||
prepared.Steps[0].lanes[0].normalizeValidators = preparedValidatorChain{}
|
||||
codec := &observedNotesCodec{}
|
||||
installObservedNotesCodec(t, prepared, codec)
|
||||
candidate := codecNotes{Items: []string{"candidate-" + string(target)}, Labels: map[string]string{"label": "original"}, Details: &codecNoteDetails{Name: "original"}}
|
||||
configureCandidateOperation(prepared, target, candidate)
|
||||
|
||||
var laterValue, serializedValue, downstreamValue codecNotes
|
||||
firstValidator := preparedValidator{
|
||||
resolved: ResolvedValidator{Binding: Binding("candidate-mutator"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"},
|
||||
typedValidate: func(_ context.Context, _ any, request typedValidationTarget) (contracts.ValidationResult, error) {
|
||||
value := request.value.(codecNotes)
|
||||
value.Items[0] = "mutated"
|
||||
value.Labels["label"] = "mutated"
|
||||
value.Details.Name = "mutated"
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
},
|
||||
}
|
||||
secondValidator := preparedValidator{
|
||||
resolved: ResolvedValidator{Binding: Binding("candidate-observer"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"},
|
||||
typedValidate: func(_ context.Context, _ any, request typedValidationTarget) (contracts.ValidationResult, error) {
|
||||
laterValue = request.value.(codecNotes)
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
},
|
||||
}
|
||||
serializedValidator := preparedValidator{
|
||||
resolved: ResolvedValidator{Binding: Binding("candidate-serialized"), Target: ValidatorTargetSerialized, ArtifactKind: "test/notes"},
|
||||
serialized: serializedValidationFunc(func(_ context.Context, request contracts.SerializedValidationRequest) (contracts.ValidationResult, error) {
|
||||
if err := json.Unmarshal(request.Content, &serializedValue); err != nil {
|
||||
return contracts.ValidationResult{}, err
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}),
|
||||
}
|
||||
setCandidateValidators(prepared, target, []preparedValidator{firstValidator, secondValidator, serializedValidator})
|
||||
|
||||
lane := &prepared.Steps[0].lanes[0]
|
||||
switch target {
|
||||
case StageExtract:
|
||||
lane.typed.merge = func(_ context.Context, _ any, request contracts.TypedMergeRequest[any]) (erasedTypedResult, error) {
|
||||
downstreamValue = request.ExtractOutputs[0].Value.(codecNotes)
|
||||
return erasedTypedResult{Value: codecNotes{Items: []string{"merged-other"}}}, nil
|
||||
}
|
||||
case StageMerge:
|
||||
lane.typed.normalize = func(_ context.Context, _ any, request contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
|
||||
downstreamValue = request.MergeOutput.Value.(codecNotes)
|
||||
return erasedTypedResult{Value: codecNotes{Items: []string{"normalized-other"}}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
debug := newCapturedDebugRecorder()
|
||||
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Debug: debug})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v, want nil", err)
|
||||
}
|
||||
if !reflect.DeepEqual(laterValue, candidate) || !reflect.DeepEqual(serializedValue, candidate) {
|
||||
t.Fatalf("validator values = typed %#v serialized %#v, want %#v", laterValue, serializedValue, candidate)
|
||||
}
|
||||
if codec.candidateCalls(candidate.Items[0]) != 1 || codec.candidateDecodeCount() != 2 || codec.finalCalls(candidate.Items[0]) != 1 {
|
||||
t.Fatalf("candidate calls = encode %d decode %d final %d, want 1, 2, 1", codec.candidateCalls(candidate.Items[0]), codec.candidateDecodeCount(), codec.finalCalls(candidate.Items[0]))
|
||||
}
|
||||
switch target {
|
||||
case StageExtract, StageMerge:
|
||||
if !reflect.DeepEqual(downstreamValue, candidate) {
|
||||
t.Fatalf("downstream value = %#v, want %#v", downstreamValue, candidate)
|
||||
}
|
||||
case StageNormalize:
|
||||
var normalized codecNotes
|
||||
if err := json.Unmarshal(output.NormalizeOutputs[0].Artifact.Content, &normalized); err != nil || !reflect.DeepEqual(normalized, candidate) {
|
||||
t.Fatalf("normalized output = %#v, %v; want %#v", normalized, err, candidate)
|
||||
}
|
||||
}
|
||||
attemptPath := fmt.Sprintf("%s/notes/attempt-01.json", target)
|
||||
if target == StageExtract {
|
||||
attemptPath = "extract/notes/chunk-000001/attempt-01.json"
|
||||
}
|
||||
payload := debug.envelope(t, attemptPath).Payload.(map[string]any)
|
||||
content, err := base64.StdEncoding.DecodeString(payload["output"].(map[string]any)["content"].(map[string]any)["content_base64"].(string))
|
||||
var debugValue codecNotes
|
||||
if err == nil {
|
||||
err = json.Unmarshal(content, &debugValue)
|
||||
}
|
||||
if err != nil || !reflect.DeepEqual(debugValue, candidate) {
|
||||
t.Fatalf("debug candidate = %#v, %v; want %#v", debugValue, err, candidate)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setCandidateValidators(prepared *PreparedPipeline, target ModuleStage, validators []preparedValidator) {
|
||||
switch target {
|
||||
case StageExtract:
|
||||
prepared.Steps[0].lanes[0].extractValidators.validators = validators
|
||||
case StageMerge:
|
||||
prepared.Steps[0].lanes[0].mergeValidators.validators = validators
|
||||
case StageNormalize:
|
||||
prepared.Steps[0].lanes[0].normalizeValidators.validators = validators
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerReportsCandidateDecodeFailure(t *testing.T) {
|
||||
prepared := preparedAttemptDebugPipeline(t)
|
||||
codec := &observedNotesCodec{candidateDecodeError: "decode-failure"}
|
||||
installObservedNotesCodec(t, prepared, codec)
|
||||
configureCandidateOperation(prepared, StageMerge, codecNotes{Items: []string{"decode-failure"}})
|
||||
setCandidateValidator(prepared, StageMerge, true)
|
||||
|
||||
_, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input")})
|
||||
var codecErr *ArtifactCodecOperationError
|
||||
if err == nil || !errors.As(err, &codecErr) || codecErr.Operation != "decode candidate" || codec.finalCalls("decode-failure") != 0 {
|
||||
t.Fatalf("Run() error = %v, codec error = %#v, final calls = %d; want contextual candidate decode failure", err, codecErr, codec.finalCalls("decode-failure"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRejectsCandidatesBeforeFinalEncoding(t *testing.T) {
|
||||
for _, target := range []ModuleStage{StageMerge, StageNormalize} {
|
||||
t.Run(string(target), func(t *testing.T) {
|
||||
|
||||
@@ -84,14 +84,14 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
|
||||
}
|
||||
|
||||
var producerWarnings []contracts.Warning
|
||||
accepted, rejection, err := runWithRetry(ctx, input.pipeline.Chunk.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) {
|
||||
retryResult, err := runWithRetry(ctx, input.pipeline.Chunk.Retries, func(attempt int) (retryAttemptResult, error) {
|
||||
attemptStarted := time.Now().UTC()
|
||||
attemptPath := path.Join("chunk", fmt.Sprintf("attempt-%02d", attempt))
|
||||
attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath)
|
||||
terminal := newAttemptTerminalRecorder(input.Debug, attemptPath, "chunk", llmScope, debugTimedEnvelope{Stage: string(StageChunk), ModuleKey: chunker.Key(), Attempt: attempt, StartedAt: attemptStarted})
|
||||
requestMetadata, metadataErr := cloneMetadata(input.Metadata)
|
||||
if metadataErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("clone chunk request metadata: %w", metadataErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone chunk request metadata: %w", metadataErr))
|
||||
}
|
||||
chunkResult, callErr := chunker.Plan(attemptCtx, contracts.ChunkRequest{
|
||||
Source: doc, SourceInput: sourceInput.Clone(), SessionID: sessionID,
|
||||
@@ -99,21 +99,21 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
|
||||
LLMProfile: input.pipeline.Chunk.LLMProfile, Metadata: requestMetadata,
|
||||
})
|
||||
if callErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("chunk source with chunker %q: %w", chunker.Key(), callErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("chunk source with chunker %q: %w", chunker.Key(), callErr))
|
||||
}
|
||||
plan, chunks, validationErr := validateAndMaterializeChunkPlan(doc, chunkResult.Plan)
|
||||
if validationErr != nil {
|
||||
attemptErr := fmt.Errorf("validate chunk plan from chunker %q: %w", chunker.Key(), validationErr)
|
||||
payload := map[string]any{"plan": debugChunkPlanEnvelope(chunkResult.Plan), "warnings": debugWarningEnvelopes(chunkResult.Warnings)}
|
||||
return false, nil, terminal.record(payload, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(payload, attemptErr)
|
||||
}
|
||||
planDigest, digestErr := source.DigestChunkPlan(plan)
|
||||
if digestErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("digest generated chunk plan: %w", digestErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("digest generated chunk plan: %w", digestErr))
|
||||
}
|
||||
producerMetadata, _, metadataErr := moduleManifestMetadata(chunker)
|
||||
if metadataErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("clone chunker manifest metadata: %w", metadataErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone chunker manifest metadata: %w", metadataErr))
|
||||
}
|
||||
profile := ""
|
||||
if input.pipeline.ChunkExecutionClass == contracts.ExecutionClassLLMBacked {
|
||||
@@ -137,7 +137,7 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
|
||||
action = "bypassed"
|
||||
}
|
||||
if candidateErr := result.setCandidate(candidate, action); candidateErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("clone generated chunk plan record: %w", candidateErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone generated chunk plan record: %w", candidateErr))
|
||||
}
|
||||
validationWarnings, rejected, validationErr := r.validateChunks(attemptCtx, doc, chunker.Key(), chunks, sourceInput, sessionID, input.pipeline.ChunkReferences.ReferenceSet, input.Metadata, input.Prepared.chunkValidators, attempt, input.Debug)
|
||||
attemptWarnings := append(cloneWarnings(chunkResult.Warnings), validationWarnings...)
|
||||
@@ -145,9 +145,16 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
|
||||
"plan": debugChunkPlanEnvelope(plan), "materialized_chunks": debugSourceChunkEnvelopes(chunks),
|
||||
"warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected),
|
||||
}
|
||||
if validationErr != nil || rejected != nil {
|
||||
if validationErr != nil {
|
||||
result.setValidation(validationWarnings, rejected, validationErr)
|
||||
return false, rejected, terminal.record(payload, validationErr)
|
||||
return retryAttemptResult{}, terminal.record(payload, validationErr)
|
||||
}
|
||||
if rejected != nil {
|
||||
result.setValidation(validationWarnings, rejected, nil)
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
return retryAttemptResult{rejection: rejected, warnings: attemptWarnings}, nil
|
||||
}
|
||||
result.chunks = chunks
|
||||
result.plan = &plan
|
||||
@@ -155,9 +162,9 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
|
||||
producerWarnings = cloneWarnings(chunkResult.Warnings)
|
||||
result.setValidation(validationWarnings, nil, nil)
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return false, nil, debugErr
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
return true, nil, nil
|
||||
return retryAttemptResult{accepted: true}, nil
|
||||
})
|
||||
if err != nil {
|
||||
if result.summary.ValidationStatus == "not_run" {
|
||||
@@ -165,9 +172,10 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
result.accepted = accepted
|
||||
result.rejection = rejection
|
||||
if !accepted {
|
||||
result.accepted = retryResult.accepted
|
||||
result.rejection = retryResult.rejection
|
||||
if !retryResult.accepted {
|
||||
result.warnings = cloneWarnings(retryResult.warnings)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,96 @@ func (a *countingInputAdapter) Parse(ctx context.Context, request contracts.Pars
|
||||
return a.InputAdapter.Parse(ctx, request)
|
||||
}
|
||||
|
||||
type cancelingDebugRecorder struct {
|
||||
cancel context.CancelFunc
|
||||
onName string
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (r *cancelingDebugRecorder) Enabled() bool { return true }
|
||||
|
||||
func (r *cancelingDebugRecorder) WriteJSON(name string, _ any) error {
|
||||
if name == r.onName {
|
||||
r.once.Do(r.cancel)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*cancelingDebugRecorder) WriteBytes(string, []byte) error { return nil }
|
||||
|
||||
type cancelingOutputEncoder struct {
|
||||
contracts.OutputEncoder
|
||||
cancel context.CancelFunc
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (e *cancelingOutputEncoder) Encode(context.Context, contracts.OutputRequest) (contracts.OutputResult, error) {
|
||||
e.calls.Add(1)
|
||||
e.cancel()
|
||||
return contracts.OutputResult{
|
||||
Files: []contracts.OutputFile{{Name: "result.txt", ContentType: "text/plain", Bytes: []byte("result")}},
|
||||
Warnings: []contracts.Warning{{ReasonCode: "returned-after-cancel"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestRunnerSkipsInputAdapterWhenContextIsAlreadyCanceled(t *testing.T) {
|
||||
prepared := preparedConcurrentPipeline(t, 1)
|
||||
adapter := &countingInputAdapter{InputAdapter: prepared.input}
|
||||
prepared.input = adapter
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := New().Run(ctx, RunInput{Prepared: prepared, RawInput: []byte("input")})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run() error = %v, want context.Canceled", err)
|
||||
}
|
||||
if got := adapter.calls.Load(); got != 0 {
|
||||
t.Fatalf("input Parse calls = %d, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerSkipsOutputEncodingAfterDebugCancellation(t *testing.T) {
|
||||
prepared := preparedConcurrentPipeline(t, 1)
|
||||
encoder := &countingOrderedOutput{}
|
||||
prepared.output = encoder
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
_, err := New().Run(ctx, RunInput{
|
||||
Prepared: prepared,
|
||||
RawInput: []byte("input"),
|
||||
Debug: &cancelingDebugRecorder{cancel: cancel, onName: "output/input.json"},
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run() error = %v, want context.Canceled", err)
|
||||
}
|
||||
if got := encoder.calls.Load(); got != 0 {
|
||||
t.Fatalf("output Encode calls = %d, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerDiscardsOutputReturnedAfterCancellation(t *testing.T) {
|
||||
prepared := preparedConcurrentPipeline(t, 1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
encoder := &cancelingOutputEncoder{OutputEncoder: prepared.output, cancel: cancel}
|
||||
prepared.output = encoder
|
||||
|
||||
output, err := New().Run(ctx, RunInput{Prepared: prepared, RawInput: []byte("input")})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run() error = %v, want context.Canceled", err)
|
||||
}
|
||||
if got := encoder.calls.Load(); got != 1 {
|
||||
t.Fatalf("output Encode calls = %d, want one", got)
|
||||
}
|
||||
if len(output.OutputFiles) != 0 {
|
||||
t.Fatalf("output files = %#v, want none", output.OutputFiles)
|
||||
}
|
||||
for _, warning := range output.Warnings {
|
||||
if warning.ReasonCode == "returned-after-cancel" {
|
||||
t.Fatalf("output warnings include encoder warning after cancellation")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerFailsGeneratedHandoffBeforeConsumerExtraction(t *testing.T) {
|
||||
prepared := preparedConcurrentPipeline(t, 1)
|
||||
input := &countingInputAdapter{InputAdapter: prepared.input}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
@@ -253,6 +254,9 @@ func (e *laneEngine) startContinuationWorkers() {
|
||||
}
|
||||
}
|
||||
|
||||
// collect is the sole owner of pending, launch, and completion accounting. It
|
||||
// must drain closed extract results and one completion for every launched
|
||||
// continuation, even after cancellation.
|
||||
func (e *laneEngine) collect() {
|
||||
resultChannel := (<-chan extractJobResult)(e.results)
|
||||
for resultChannel != nil || len(e.pending) > 0 || e.completed < e.launched {
|
||||
@@ -406,20 +410,20 @@ func (r *Runner) runExtractJob(ctx context.Context, input RunInput, doc *source.
|
||||
var accepted erasedExtractArtifact
|
||||
var serialized CheckpointArtifact
|
||||
var acceptedWarnings []contracts.Warning
|
||||
ok, rejection, err := runWithRetry(ctx, lane.Extract.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) {
|
||||
retryResult, err := runWithRetry(ctx, lane.Extract.Retries, func(attempt int) (retryAttemptResult, error) {
|
||||
started := time.Now().UTC()
|
||||
attemptPath := path.Join("extract", debugPathComponent(lane.ID), fmt.Sprintf("chunk-%06d", chunk.Index+1), fmt.Sprintf("attempt-%02d", attempt))
|
||||
attemptPath := path.Join("extract", fileio.EncodePathComponent(lane.ID), fmt.Sprintf("chunk-%06d", chunk.Index+1), fmt.Sprintf("attempt-%02d", attempt))
|
||||
attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath)
|
||||
terminal := newAttemptTerminalRecorder(input.Debug, attemptPath, "extract", llmScope, debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, Attempt: attempt, StartedAt: started})
|
||||
requestMetadata, metadataErr := cloneMetadata(input.Metadata)
|
||||
if metadataErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("clone extract request metadata: %w", metadataErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone extract request metadata: %w", metadataErr))
|
||||
}
|
||||
extractReferences := operationReferenceSet(input, lane.ExtractReferences)
|
||||
extracted, callErr := typed.extract(attemptCtx, typed.extractor, contracts.TypedExtractionRequest{Source: doc, Chunk: &chunk, SourceInput: chunkInputMaterial(sourceInput, chunk), SessionID: sessionID, References: CloneReferenceSet(extractReferences), LLMProfile: lane.Extract.LLMProfile, Metadata: requestMetadata})
|
||||
if callErr != nil {
|
||||
attemptErr := fmt.Errorf("extract lane %q chunk %q with extractor %q: %w", lane.ID, chunk.ID, lane.Extract.Module, callErr)
|
||||
return false, nil, terminal.record(nil, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(nil, attemptErr)
|
||||
}
|
||||
artifact := erasedExtractArtifact{LaneID: lane.ID, ExtractorKey: lane.Extract.Module, SourceID: doc.ID, ChunkID: chunk.ID, ChunkIndex: chunk.Index, ChunkRef: chunk.Ref, Value: extracted.Value}
|
||||
attemptWarnings := cloneWarnings(extracted.Warnings)
|
||||
@@ -427,31 +431,38 @@ func (r *Runner) runExtractJob(ctx context.Context, input RunInput, doc *source.
|
||||
if encodeErr != nil {
|
||||
attemptErr := fmt.Errorf("serialize extract candidate for lane %q chunk %q: %w", lane.ID, chunk.ID, encodeErr)
|
||||
payload := map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}
|
||||
return false, nil, terminal.record(payload, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(payload, attemptErr)
|
||||
}
|
||||
serializedCandidate.ChunkID, serializedCandidate.ChunkIndex, serializedCandidate.ChunkRef = artifact.ChunkID, artifact.ChunkIndex, artifact.ChunkRef
|
||||
warnings, rejected, validateErr := r.validateTypedArtifact(attemptCtx, typed.codec, typedValidationTarget{stage: StageExtract, stepID: input.stepID, laneID: lane.ID, moduleKey: lane.Extract.Module, source: doc, sourceID: doc.ID, sourceInput: chunkInputMaterial(sourceInput, chunk), sessionID: sessionID, references: extractReferences, metadata: input.Metadata, chunk: &chunk, ref: chunk.Ref, value: extracted.Value, candidate: &serializedCandidate}, state.prepared.extractValidators, attempt, input.Debug)
|
||||
attemptWarnings = append(attemptWarnings, warnings...)
|
||||
payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected)}
|
||||
if validateErr != nil || rejected != nil {
|
||||
return false, rejected, terminal.record(payload, validateErr)
|
||||
if validateErr != nil {
|
||||
return retryAttemptResult{}, terminal.record(payload, validateErr)
|
||||
}
|
||||
if rejected != nil {
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
return retryAttemptResult{rejection: rejected, warnings: attemptWarnings}, nil
|
||||
}
|
||||
stored, encodeErr := checkpointArtifact(typed.codec, artifact.LaneID, artifact.ExtractorKey, artifact.SourceID, artifact.Value)
|
||||
if encodeErr != nil {
|
||||
attemptErr := fmt.Errorf("serialize accepted extract output for lane %q chunk %q: %w", lane.ID, chunk.ID, encodeErr)
|
||||
return false, nil, terminal.record(payload, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(payload, attemptErr)
|
||||
}
|
||||
stored.ChunkID, stored.ChunkIndex, stored.ChunkRef = artifact.ChunkID, artifact.ChunkIndex, artifact.ChunkRef
|
||||
accepted, serialized = artifact, stored
|
||||
acceptedWarnings = attemptWarnings
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return false, nil, debugErr
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
return true, nil, nil
|
||||
return retryAttemptResult{accepted: true}, nil
|
||||
})
|
||||
result.err = err
|
||||
if err == nil && !ok {
|
||||
result.rejected = rejection
|
||||
if err == nil && !retryResult.accepted {
|
||||
result.rejected = retryResult.rejection
|
||||
result.warnings = cloneWarnings(retryResult.warnings)
|
||||
return result
|
||||
}
|
||||
result.value, result.serialized, result.warnings = accepted, serialized, acceptedWarnings
|
||||
@@ -469,6 +480,7 @@ func finalizeLaneExtract(checkpoints CheckpointRecorder, stepID string, state *l
|
||||
result := state.results[index]
|
||||
if result.rejected != nil {
|
||||
state.rejected = append(state.rejected, *result.rejected)
|
||||
state.warnings = append(state.warnings, result.warnings...)
|
||||
continue
|
||||
}
|
||||
state.values = append(state.values, result.value)
|
||||
@@ -498,10 +510,10 @@ func (r *Runner) continueLane(ctx context.Context, input RunInput, checkpoints C
|
||||
}
|
||||
local.Warnings = append(local.Warnings, cloneWarnings(results.warnings)...)
|
||||
local.Rejected = append(local.Rejected, cloneRejectedOutputs(results.rejected)...)
|
||||
if err := writeDebugTimed(input.Debug, path.Join("extract", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": results.decision.Reused, "decision": results.decision, "source": debugSourceDocumentEnvelope(doc), "chunks": debugSourceChunkEnvelopes(chunks), "options": redactSensitiveMap(lane.Extract.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
|
||||
if err := writeDebugTimed(input.Debug, path.Join("extract", fileio.EncodePathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": results.decision.Reused, "decision": results.decision, "source": debugSourceDocumentEnvelope(doc), "chunks": debugSourceChunkEnvelopes(chunks), "options": redactSensitiveMap(lane.Extract.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
|
||||
return local, &laneRunError{stage: StageExtract, err: err}
|
||||
}
|
||||
if err := writeDebugTimed(input.Debug, path.Join("extract", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": results.decision.Reused, "outputs": debugCheckpointArtifacts(results.serialized), "rejected": debugRejectedOutputEnvelopes(results.rejected), "warnings": debugWarningEnvelopes(results.warnings)}}); err != nil {
|
||||
if err := writeDebugTimed(input.Debug, path.Join("extract", fileio.EncodePathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": results.decision.Reused, "outputs": debugCheckpointArtifacts(results.serialized), "rejected": debugRejectedOutputEnvelopes(results.rejected), "warnings": debugWarningEnvelopes(results.warnings)}}); err != nil {
|
||||
return local, &laneRunError{stage: StageExtract, err: err}
|
||||
}
|
||||
if len(results.accepted) == 0 {
|
||||
|
||||
142
internal/framework/pipeline/runner_rejection_warnings_test.go
Normal file
142
internal/framework/pipeline/runner_rejection_warnings_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
type warningChunker struct {
|
||||
key string
|
||||
plan source.ChunkPlan
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *warningChunker) Key() string { return c.key }
|
||||
func (*warningChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
func (c *warningChunker) Plan(context.Context, contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
|
||||
c.calls++
|
||||
return contracts.ChunkPlanResult{
|
||||
Plan: source.CloneChunkPlan(c.plan),
|
||||
Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", c.calls), ReasonCode: "operation", Message: "operation warning"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type chunkValidationFunc struct {
|
||||
name string
|
||||
validate func(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error)
|
||||
}
|
||||
|
||||
func (v chunkValidationFunc) Name() string { return v.name }
|
||||
func (chunkValidationFunc) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
}
|
||||
func (v chunkValidationFunc) Validate(ctx context.Context, request contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
|
||||
return v.validate(ctx, request)
|
||||
}
|
||||
|
||||
func TestRunnerPromotesOnlyTerminalRejectionWarnings(t *testing.T) {
|
||||
for _, target := range []ModuleStage{StageChunk, StageExtract, StageMerge, StageNormalize} {
|
||||
t.Run(string(target), func(t *testing.T) {
|
||||
prepared := preparedAttemptDebugPipeline(t)
|
||||
lane := &prepared.Steps[0].lanes[0]
|
||||
attempts := 0
|
||||
first := func() contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: true, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("validator-%d", attempts), ReasonCode: "validator", Message: "validator warning"}}}
|
||||
}
|
||||
reject := func() contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "rejected"}
|
||||
}
|
||||
debug := newCapturedDebugRecorder()
|
||||
recorder := &extractCaptureRecorder{CheckpointRecorder: NoopCheckpointRecorder()}
|
||||
|
||||
switch target {
|
||||
case StageChunk:
|
||||
chunker := prepared.chunker.(*typedTestChunker)
|
||||
prepared.chunker = &warningChunker{key: prepared.resolved.Chunk.Module, plan: source.CloneChunkPlan(chunker.plan)}
|
||||
prepared.resolved.Chunk.Retries = 1
|
||||
prepared.chunkValidators.validators = []preparedValidator{
|
||||
{resolved: ResolvedValidator{Binding: Binding("warning-approval"), Target: ValidatorTargetChunk}, chunk: chunkValidationFunc{name: "warning-approval", validate: func(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
|
||||
return first(), nil
|
||||
}}},
|
||||
{resolved: ResolvedValidator{Binding: Binding("warning-rejection"), Target: ValidatorTargetChunk}, chunk: chunkValidationFunc{name: "warning-rejection", validate: func(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
|
||||
return reject(), nil
|
||||
}}},
|
||||
}
|
||||
chunkerWithWarnings := prepared.chunker.(*warningChunker)
|
||||
first = func() contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: true, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("validator-%d", chunkerWithWarnings.calls), ReasonCode: "validator", Message: "validator warning"}}}
|
||||
}
|
||||
case StageExtract:
|
||||
lane.resolved.Extract.Retries = 1
|
||||
installExtractOperation(prepared, 0, func(context.Context, contracts.TypedExtractionRequest) (erasedTypedResult, error) {
|
||||
attempts++
|
||||
return erasedTypedResult{Value: codecNotes{Items: []string{"extract"}}, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", attempts), ReasonCode: "operation", Message: "operation warning"}}}, nil
|
||||
})
|
||||
lane.extractValidators.validators = rejectionWarningTypedValidators(first, reject)
|
||||
case StageMerge:
|
||||
lane.resolved.Merge.Retries = 1
|
||||
lane.typed.merge = func(context.Context, any, contracts.TypedMergeRequest[any]) (erasedTypedResult, error) {
|
||||
attempts++
|
||||
return erasedTypedResult{Value: codecNotes{Items: []string{"merge"}}, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", attempts), ReasonCode: "operation", Message: "operation warning"}}}, nil
|
||||
}
|
||||
lane.mergeValidators.validators = rejectionWarningTypedValidators(first, reject)
|
||||
case StageNormalize:
|
||||
lane.resolved.Normalize.Retries = 1
|
||||
lane.typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
|
||||
attempts++
|
||||
return erasedTypedResult{Value: codecNotes{Items: []string{"normalize"}}, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", attempts), ReasonCode: "operation", Message: "operation warning"}}}, nil
|
||||
}
|
||||
lane.normalizeValidators.validators = rejectionWarningTypedValidators(first, reject)
|
||||
}
|
||||
|
||||
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Checkpoints: recorder, Debug: debug})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v, want nil", err)
|
||||
}
|
||||
wantScopes := []string{"operation-2", "validator-2"}
|
||||
if got := rejectionWarningScopes(output.Warnings); !reflect.DeepEqual(got, wantScopes) {
|
||||
t.Fatalf("published warning scopes = %#v, want %#v", got, wantScopes)
|
||||
}
|
||||
if len(output.Rejected) != 1 || output.Rejected[0].AttemptCount != 2 {
|
||||
t.Fatalf("rejections = %#v, want final rejection after two attempts", output.Rejected)
|
||||
}
|
||||
if target == StageExtract && !reflect.DeepEqual(rejectionWarningScopes(recorder.checkpoint.Warnings), wantScopes) {
|
||||
t.Fatalf("extract checkpoint warnings = %#v, want %#v", recorder.checkpoint.Warnings, wantScopes)
|
||||
}
|
||||
attemptPath := fmt.Sprintf("%s/notes/attempt-01.json", target)
|
||||
if target == StageChunk {
|
||||
attemptPath = "chunk/attempt-01.json"
|
||||
} else if target == StageExtract {
|
||||
attemptPath = "extract/notes/chunk-000001/attempt-01.json"
|
||||
}
|
||||
if !strings.Contains(string(debug.json[attemptPath]), "operation-1") || !strings.Contains(string(debug.json[attemptPath]), "validator-1") {
|
||||
t.Fatalf("first attempt debug = %s, want discarded warnings", debug.json[attemptPath])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func rejectionWarningTypedValidators(first func() contracts.ValidationResult, reject func() contracts.ValidationResult) []preparedValidator {
|
||||
return []preparedValidator{
|
||||
{resolved: ResolvedValidator{Binding: Binding("warning-approval"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"}, typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) {
|
||||
return first(), nil
|
||||
}},
|
||||
{resolved: ResolvedValidator{Binding: Binding("warning-rejection"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"}, typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) {
|
||||
return reject(), nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func rejectionWarningScopes(warnings []contracts.Warning) []string {
|
||||
scopes := make([]string, len(warnings))
|
||||
for index := range warnings {
|
||||
scopes[index] = warnings[index].Scope
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
@@ -329,7 +329,7 @@ func TestRunnerKeepsExtractModuleAndValidatorLLMCallsIsolated(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v, want nil", err)
|
||||
}
|
||||
module := debug.envelope(t, "extract/notes/chunk-000001/attempt-01.json")
|
||||
validator := debug.envelope(t, "validate/extract/notes/typed~2fextract-notes/01-llm-check-attempt-01.json")
|
||||
validator := debug.envelope(t, "validate/extract/notes/typed%2Fextract-notes/01-llm-check-attempt-01.json")
|
||||
if len(module.LLMCalls) != 1 || !strings.Contains(module.LLMCalls[0].ResponsePath, "extract/notes/chunk-000001/attempt-01/") {
|
||||
t.Fatalf("module LLM calls = %#v, want extract module call only", module.LLMCalls)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
@@ -232,7 +234,7 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints
|
||||
return stageResult, err
|
||||
}
|
||||
mergeDecision = mergeResolution.decision
|
||||
if err := writeDebugTimed(input.Debug, path.Join("merge", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "decision": mergeDecision, "source": debugSourceDocumentEnvelope(doc), "extract_outputs": debugCheckpointArtifacts(extracts.serialized), "options": redactSensitiveMap(lane.Merge.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
|
||||
if err := writeDebugTimed(input.Debug, path.Join("merge", fileio.EncodePathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "decision": mergeDecision, "source": debugSourceDocumentEnvelope(doc), "extract_outputs": debugCheckpointArtifacts(extracts.serialized), "options": redactSensitiveMap(lane.Merge.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
var merged erasedMergeArtifact
|
||||
@@ -247,52 +249,59 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints
|
||||
if err := checkpointMergeRunning(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
ok, rejection, runErr := runWithRetry(ctx, lane.Merge.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) {
|
||||
retryResult, runErr := runWithRetry(ctx, lane.Merge.Retries, func(attempt int) (retryAttemptResult, error) {
|
||||
started := time.Now().UTC()
|
||||
attemptPath := path.Join("merge", debugPathComponent(lane.ID), fmt.Sprintf("attempt-%02d", attempt))
|
||||
attemptPath := path.Join("merge", fileio.EncodePathComponent(lane.ID), fmt.Sprintf("attempt-%02d", attempt))
|
||||
attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath)
|
||||
terminal := newAttemptTerminalRecorder(input.Debug, attemptPath, "merge", llmScope, debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, Attempt: attempt, StartedAt: started})
|
||||
requestMetadata, metadataErr := cloneMetadata(input.Metadata)
|
||||
if metadataErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("clone merge request metadata: %w", metadataErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone merge request metadata: %w", metadataErr))
|
||||
}
|
||||
result, callErr := typed.merge(attemptCtx, typed.merger, contracts.TypedMergeRequest[any]{Source: doc, LaneID: lane.ID, ExtractOutputs: mergeInputs, SourceInput: sourceInput.Clone(), SessionID: sessionID, References: CloneReferenceSet(mergeReferences), LLMProfile: lane.Merge.LLMProfile, Metadata: requestMetadata})
|
||||
if callErr != nil {
|
||||
attemptErr := fmt.Errorf("merge lane %q with merger %q: %w", lane.ID, lane.Merge.Module, callErr)
|
||||
return false, nil, terminal.record(nil, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(nil, attemptErr)
|
||||
}
|
||||
candidate := erasedMergeArtifact{LaneID: lane.ID, MergerKey: lane.Merge.Module, SourceID: doc.ID, Value: result.Value}
|
||||
attemptWarnings := cloneWarnings(result.Warnings)
|
||||
serializedCandidate, encodeErr := serializeCandidateArtifact(typed.codec, candidate.LaneID, candidate.MergerKey, candidate.SourceID, candidate.Value)
|
||||
if encodeErr != nil {
|
||||
attemptErr := fmt.Errorf("serialize merge candidate for lane %q: %w", lane.ID, encodeErr)
|
||||
return false, nil, terminal.record(map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}, attemptErr)
|
||||
}
|
||||
warnings, rejected, validateErr := r.validateTypedArtifact(attemptCtx, typed.codec, typedValidationTarget{stage: StageMerge, stepID: input.stepID, laneID: lane.ID, moduleKey: lane.Merge.Module, source: doc, sourceID: doc.ID, sourceInput: sourceInput.Clone(), sessionID: sessionID, references: mergeReferences, metadata: input.Metadata, value: result.Value, candidate: &serializedCandidate}, prepared.mergeValidators, attempt, input.Debug)
|
||||
attemptWarnings = append(attemptWarnings, warnings...)
|
||||
payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected)}
|
||||
if validateErr != nil || rejected != nil {
|
||||
return false, rejected, terminal.record(payload, validateErr)
|
||||
if validateErr != nil {
|
||||
return retryAttemptResult{}, terminal.record(payload, validateErr)
|
||||
}
|
||||
if rejected != nil {
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
return retryAttemptResult{rejection: rejected, warnings: attemptWarnings}, nil
|
||||
}
|
||||
stored, encodeErr := checkpointArtifact(typed.codec, candidate.LaneID, candidate.MergerKey, candidate.SourceID, candidate.Value)
|
||||
if encodeErr != nil {
|
||||
attemptErr := fmt.Errorf("serialize accepted merge output for lane %q: %w", lane.ID, encodeErr)
|
||||
return false, nil, terminal.record(payload, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(payload, attemptErr)
|
||||
}
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return false, nil, debugErr
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
merged, serializedMerge = candidate, stored
|
||||
mergeWarnings = attemptWarnings
|
||||
return true, nil, nil
|
||||
return retryAttemptResult{accepted: true}, nil
|
||||
})
|
||||
if runErr != nil {
|
||||
_ = checkpointMergeFailed(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps, runErr)
|
||||
return stageResult, runErr
|
||||
}
|
||||
if !ok {
|
||||
output.Rejected = append(output.Rejected, *rejection)
|
||||
if err := checkpointMergeRejected(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps, *rejection); err != nil {
|
||||
if !retryResult.accepted {
|
||||
output.Warnings = append(output.Warnings, retryResult.warnings...)
|
||||
output.Rejected = append(output.Rejected, *retryResult.rejection)
|
||||
if err := checkpointMergeRejected(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps, *retryResult.rejection); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
stageResult.terminal = true
|
||||
@@ -303,7 +312,7 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints
|
||||
return stageResult, err
|
||||
}
|
||||
}
|
||||
if err := writeDebugTimed(input.Debug, path.Join("merge", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedMerge), "warnings": debugWarningEnvelopes(mergeWarnings)}}); err != nil {
|
||||
if err := writeDebugTimed(input.Debug, path.Join("merge", fileio.EncodePathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedMerge), "warnings": debugWarningEnvelopes(mergeWarnings)}}); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
stageResult.artifact = merged
|
||||
@@ -329,7 +338,7 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
|
||||
return stageResult, err
|
||||
}
|
||||
normalizeDecision = normalizeResolution.decision
|
||||
if err := writeDebugTimed(input.Debug, path.Join("normalize", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "decision": normalizeDecision, "source": debugSourceDocumentEnvelope(doc), "merge_output": debugCheckpointArtifact(serializedMerge), "options": redactSensitiveMap(lane.Normalize.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
|
||||
if err := writeDebugTimed(input.Debug, path.Join("normalize", fileio.EncodePathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "decision": normalizeDecision, "source": debugSourceDocumentEnvelope(doc), "merge_output": debugCheckpointArtifact(serializedMerge), "options": redactSensitiveMap(lane.Normalize.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
var serializedNormalize CheckpointArtifact
|
||||
@@ -342,30 +351,30 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
|
||||
if err := checkpointNormalizeRunning(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
ok, rejection, runErr := runWithRetry(ctx, lane.Normalize.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) {
|
||||
retryResult, runErr := runWithRetry(ctx, lane.Normalize.Retries, func(attempt int) (retryAttemptResult, error) {
|
||||
started := time.Now().UTC()
|
||||
attemptPath := path.Join("normalize", debugPathComponent(lane.ID), fmt.Sprintf("attempt-%02d", attempt))
|
||||
attemptPath := path.Join("normalize", fileio.EncodePathComponent(lane.ID), fmt.Sprintf("attempt-%02d", attempt))
|
||||
attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath)
|
||||
terminal := newAttemptTerminalRecorder(input.Debug, attemptPath, "normalize", llmScope, debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, Attempt: attempt, StartedAt: started})
|
||||
requestMetadata, metadataErr := cloneMetadata(input.Metadata)
|
||||
if metadataErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("clone normalize request metadata: %w", metadataErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone normalize request metadata: %w", metadataErr))
|
||||
}
|
||||
result, callErr := typed.normalize(attemptCtx, typed.normalizer, contracts.TypedNormalizeRequest[any]{Source: doc, LaneID: lane.ID, MergeOutput: contracts.MergeArtifact[any]{LaneID: lane.ID, MergerKey: lane.Merge.Module, SourceID: doc.ID, Value: merged.Value}, SourceInput: sourceInput.Clone(), SessionID: sessionID, References: CloneReferenceSet(normalizeReferences), LLMProfile: lane.Normalize.LLMProfile, Metadata: requestMetadata})
|
||||
if callErr != nil {
|
||||
attemptErr := fmt.Errorf("normalize lane %q with normalizer %q: %w", lane.ID, lane.Normalize.Module, callErr)
|
||||
return false, nil, terminal.record(nil, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(nil, attemptErr)
|
||||
}
|
||||
attemptWarnings := cloneWarnings(result.Warnings)
|
||||
serializedCandidate, encodeErr := serializeCandidateArtifact(typed.codec, lane.ID, lane.Normalize.Module, doc.ID, result.Value)
|
||||
if encodeErr != nil {
|
||||
attemptErr := fmt.Errorf("serialize normalize candidate for lane %q: %w", lane.ID, encodeErr)
|
||||
return false, nil, terminal.record(map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}, attemptErr)
|
||||
}
|
||||
var retryPayload map[string]any
|
||||
if result.Retry != nil {
|
||||
if err := validateNormalizeRetry(result.Retry); err != nil {
|
||||
return false, nil, terminal.record(map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings)}, fmt.Errorf("normalize lane %q returned invalid retry directive: %w", lane.ID, err))
|
||||
return retryAttemptResult{}, terminal.record(map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings)}, fmt.Errorf("normalize lane %q returned invalid retry directive: %w", lane.ID, err))
|
||||
}
|
||||
retryRemaining := attempt <= lane.Normalize.Retries
|
||||
retryPayload = map[string]any{
|
||||
@@ -376,7 +385,7 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
|
||||
}
|
||||
if retryRemaining {
|
||||
payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "retry": retryPayload}
|
||||
return false, nil, terminal.record(payload, nil)
|
||||
return retryAttemptResult{}, terminal.record(payload, nil)
|
||||
}
|
||||
attemptWarnings = append(attemptWarnings, cloneWarnings(result.Retry.FallbackWarnings)...)
|
||||
}
|
||||
@@ -386,28 +395,35 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
|
||||
if retryPayload != nil {
|
||||
payload["retry"] = retryPayload
|
||||
}
|
||||
if validateErr != nil || rejected != nil {
|
||||
return false, rejected, terminal.record(payload, validateErr)
|
||||
if validateErr != nil {
|
||||
return retryAttemptResult{}, terminal.record(payload, validateErr)
|
||||
}
|
||||
if rejected != nil {
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
return retryAttemptResult{rejection: rejected, warnings: attemptWarnings}, nil
|
||||
}
|
||||
stored, encodeErr := checkpointArtifact(typed.codec, lane.ID, lane.Normalize.Module, doc.ID, result.Value)
|
||||
if encodeErr != nil {
|
||||
attemptErr := fmt.Errorf("serialize accepted normalize output for lane %q: %w", lane.ID, encodeErr)
|
||||
return false, nil, terminal.record(payload, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(payload, attemptErr)
|
||||
}
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return false, nil, debugErr
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
serializedNormalize = stored
|
||||
normalizeWarnings = attemptWarnings
|
||||
return true, nil, nil
|
||||
return retryAttemptResult{accepted: true}, nil
|
||||
})
|
||||
if runErr != nil {
|
||||
_ = checkpointNormalizeFailed(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps, runErr)
|
||||
return stageResult, runErr
|
||||
}
|
||||
if !ok {
|
||||
output.Rejected = append(output.Rejected, *rejection)
|
||||
if err := checkpointNormalizeRejected(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps, *rejection); err != nil {
|
||||
if !retryResult.accepted {
|
||||
output.Warnings = append(output.Warnings, retryResult.warnings...)
|
||||
output.Rejected = append(output.Rejected, *retryResult.rejection)
|
||||
if err := checkpointNormalizeRejected(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps, *retryResult.rejection); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
return stageResult, nil
|
||||
@@ -417,7 +433,7 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
|
||||
return stageResult, err
|
||||
}
|
||||
}
|
||||
if err := writeDebugTimed(input.Debug, path.Join("normalize", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedNormalize), "warnings": debugWarningEnvelopes(normalizeWarnings)}}); err != nil {
|
||||
if err := writeDebugTimed(input.Debug, path.Join("normalize", fileio.EncodePathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedNormalize), "warnings": debugWarningEnvelopes(normalizeWarnings)}}); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
stageResult.serialized = serializedNormalize
|
||||
@@ -469,7 +485,7 @@ func (r *Runner) validateTypedArtifact(ctx context.Context, codec artifactCodecE
|
||||
var result contracts.ValidationResult
|
||||
var err error
|
||||
started := time.Now().UTC()
|
||||
attemptPath := path.Join("validate", debugPathComponent(string(target.stage)), debugPathComponent(target.laneID), debugPathComponent(target.moduleKey), fmt.Sprintf("%02d-%s-attempt-%02d", index+1, debugPathComponent(binding.Module), attempt))
|
||||
attemptPath := path.Join("validate", fileio.EncodePathComponent(string(target.stage)), fileio.EncodePathComponent(target.laneID), fileio.EncodePathComponent(target.moduleKey), fmt.Sprintf("%02d-%s-attempt-%02d", index+1, fileio.EncodePathComponent(binding.Module), attempt))
|
||||
validatorCtx, llmScope := withIsolatedDebugLLMScope(ctx, attemptPath)
|
||||
requestTarget := target
|
||||
requestTarget.sourceInput = target.sourceInput.Clone()
|
||||
@@ -488,6 +504,12 @@ func (r *Runner) validateTypedArtifact(ctx context.Context, codec artifactCodecE
|
||||
}
|
||||
switch item.resolved.Target {
|
||||
case ValidatorTargetTyped:
|
||||
candidateValue, decodeErr := decodeTypedValidationCandidate(codec, *target.candidate)
|
||||
if decodeErr != nil {
|
||||
err = fmt.Errorf("decode %s candidate for typed validator %q: %w", target.stage, binding.Module, decodeErr)
|
||||
break
|
||||
}
|
||||
requestTarget.value = candidateValue
|
||||
requestTarget.llmProfile = binding.LLMProfile
|
||||
result, err = item.typedValidate(validatorCtx, item.typed, requestTarget)
|
||||
case ValidatorTargetSerialized:
|
||||
@@ -547,3 +569,15 @@ func validationCandidateArtifact(codec artifactCodecEntry, target typedValidatio
|
||||
}
|
||||
return serializeCandidateArtifact(codec, target.laneID, target.moduleKey, target.sourceID, target.value)
|
||||
}
|
||||
|
||||
func decodeTypedValidationCandidate(codec artifactCodecEntry, candidate CheckpointArtifact) (any, error) {
|
||||
value, err := codec.decodeCandidate(candidate.Artifact.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actualType := reflect.TypeOf(value)
|
||||
if actualType != codec.valueType {
|
||||
return nil, newArtifactCodecTypeError("decode candidate", codec.spec.Kind, codec.valueType, actualType)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -500,7 +500,7 @@ func registriesFromModuleCatalog(catalog ModuleCatalog) Registries {
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterArtifactCodec[T any](t *testing.T, registry *ArtifactCodecRegistry, codec contracts.ArtifactCodec[T]) {
|
||||
func mustRegisterArtifactCodec[T any](t *testing.T, registry *ArtifactCodecRegistry, codec contracts.CandidateArtifactCodec[T]) {
|
||||
t.Helper()
|
||||
if err := RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v", err)
|
||||
|
||||
@@ -71,7 +71,11 @@ func normalizeValidatorChainMapping(mapping ValidatorChainMapping) (ValidatorCha
|
||||
if strings.TrimSpace(validator.Module) == "" {
|
||||
return ValidatorChainMapping{}, fmt.Errorf("validator chain for %q %q has empty validator key at index %d", normalized.Stage, normalized.Module, i)
|
||||
}
|
||||
normalized.Validators[i] = resolveBinding(validator, "")
|
||||
resolved, err := resolveBinding(validator, "", fmt.Sprintf("validator chain for %q %q validator %d reference slot", normalized.Stage, normalized.Module, i))
|
||||
if err != nil {
|
||||
return ValidatorChainMapping{}, err
|
||||
}
|
||||
normalized.Validators[i] = resolved
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
@@ -150,7 +150,13 @@ func RegisterChunkValidatorBuilder(registry *ValidatorRegistry, spec ValidatorSp
|
||||
if registry.chunkEntries == nil {
|
||||
registry.chunkEntries = make(map[string]chunkValidatorEntry)
|
||||
}
|
||||
registry.chunkEntries[normalizedSpec.Key] = chunkValidatorEntry{spec: normalizedSpec, validateOptions: validateOptions, builder: builder}
|
||||
registry.chunkEntries[normalizedSpec.Key] = chunkValidatorEntry{
|
||||
spec: normalizedSpec,
|
||||
validateOptions: validateOptions,
|
||||
builder: func(request BuildRequest) (contracts.ChunkValidator, error) {
|
||||
return builder(cloneBuildRequest(request))
|
||||
},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -187,7 +193,13 @@ func RegisterSerializedValidatorBuilder(registry *ValidatorRegistry, spec Serial
|
||||
if registry.serializedEntries == nil {
|
||||
registry.serializedEntries = make(map[string]serializedValidatorEntry)
|
||||
}
|
||||
registry.serializedEntries[spec.Key] = serializedValidatorEntry{spec: spec, validateOptions: validateOptions, builder: builder}
|
||||
registry.serializedEntries[spec.Key] = serializedValidatorEntry{
|
||||
spec: spec,
|
||||
validateOptions: validateOptions,
|
||||
builder: func(request BuildRequest) (contracts.SerializedValidator, error) {
|
||||
return builder(cloneBuildRequest(request))
|
||||
},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package promptfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/readonlyfs"
|
||||
)
|
||||
|
||||
// ModulePromptFile maps a module-owned embedded prompt file into the registered
|
||||
@@ -37,7 +35,8 @@ func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile,
|
||||
if moduleFS == nil {
|
||||
return nil, fmt.Errorf("module prompt filesystem must not be nil")
|
||||
}
|
||||
assets := make(promptMapFS, len(files)+len(sharedFiles))
|
||||
assets := make(map[string][]byte, len(files)+len(sharedFiles))
|
||||
moduleDestinations := make(map[string]struct{}, len(files))
|
||||
for _, file := range files {
|
||||
name, err := cleanPromptPath(file.Name)
|
||||
if err != nil {
|
||||
@@ -46,6 +45,11 @@ func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile,
|
||||
if strings.Contains(name, "/") {
|
||||
return nil, fmt.Errorf("module prompt file name %q must not contain path separators", file.Name)
|
||||
}
|
||||
destination := "assets/prompts/" + cleanModuleDir + "/" + name
|
||||
if _, exists := moduleDestinations[destination]; exists {
|
||||
return nil, fmt.Errorf("duplicate module prompt destination %q", destination)
|
||||
}
|
||||
moduleDestinations[destination] = struct{}{}
|
||||
filePath, err := cleanPromptPath(file.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("module prompt file path %q: %w", file.Path, err)
|
||||
@@ -54,9 +58,10 @@ func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile,
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read module prompt asset %s: %w", filePath, err)
|
||||
}
|
||||
assets["assets/prompts/"+cleanModuleDir+"/"+name] = append([]byte(nil), data...)
|
||||
assets[destination] = data
|
||||
}
|
||||
|
||||
sharedDestinations := make(map[string]struct{}, len(sharedFiles))
|
||||
for _, file := range sharedFiles {
|
||||
name, err := cleanPromptPath(file.Name)
|
||||
if err != nil {
|
||||
@@ -65,6 +70,11 @@ func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile,
|
||||
if strings.Contains(name, "/") {
|
||||
return nil, fmt.Errorf("shared prompt file name %q must not contain path separators", file.Name)
|
||||
}
|
||||
destination := "assets/prompts/" + cleanModuleDir + "/sharedassets/" + name
|
||||
if _, exists := sharedDestinations[destination]; exists {
|
||||
return nil, fmt.Errorf("duplicate sharedassets prompt destination %q", destination)
|
||||
}
|
||||
sharedDestinations[destination] = struct{}{}
|
||||
if file.FS == nil {
|
||||
return nil, fmt.Errorf("shared prompt file %q filesystem must not be nil", file.Name)
|
||||
}
|
||||
@@ -76,77 +86,13 @@ func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile,
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read shared prompt asset %s: %w", filePath, err)
|
||||
}
|
||||
assets["assets/prompts/"+cleanModuleDir+"/sharedassets/"+name] = append([]byte(nil), data...)
|
||||
assets[destination] = data
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
type promptMapFS map[string][]byte
|
||||
|
||||
func (m promptMapFS) Open(name string) (fs.File, error) {
|
||||
cleaned, err := cleanPromptFSPath(name)
|
||||
fsys, err := readonlyfs.New(assets)
|
||||
if err != nil {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
|
||||
return nil, fmt.Errorf("construct prompt filesystem: %w", err)
|
||||
}
|
||||
if data, ok := m[cleaned]; ok {
|
||||
return &promptFile{
|
||||
reader: bytes.NewReader(data),
|
||||
info: promptFileInfo{name: path.Base(cleaned), size: int64(len(data))},
|
||||
}, nil
|
||||
}
|
||||
entries := m.dirEntries(cleaned)
|
||||
if entries != nil {
|
||||
return &promptDir{name: path.Base(cleaned), entries: entries}, nil
|
||||
}
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
|
||||
func (m promptMapFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
cleaned, err := cleanPromptFSPath(name)
|
||||
if err != nil {
|
||||
return nil, &fs.PathError{Op: "readdir", Path: name, Err: err}
|
||||
}
|
||||
entries := m.dirEntries(cleaned)
|
||||
if entries == nil {
|
||||
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (m promptMapFS) dirEntries(dir string) []fs.DirEntry {
|
||||
children := map[string]promptDirEntry{}
|
||||
prefix := ""
|
||||
if dir != "." {
|
||||
prefix = dir + "/"
|
||||
}
|
||||
for name, data := range m {
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimPrefix(name, prefix)
|
||||
if rest == "" {
|
||||
continue
|
||||
}
|
||||
childName, _, hasSlash := strings.Cut(rest, "/")
|
||||
entry := promptDirEntry{name: childName, dir: hasSlash}
|
||||
if !hasSlash {
|
||||
entry.size = int64(len(data))
|
||||
}
|
||||
children[childName] = entry
|
||||
}
|
||||
if len(children) == 0 {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(children))
|
||||
for name := range children {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
entries := make([]fs.DirEntry, 0, len(names))
|
||||
for _, name := range names {
|
||||
entries = append(entries, children[name])
|
||||
}
|
||||
return entries
|
||||
return fsys, nil
|
||||
}
|
||||
|
||||
func cleanPromptPath(name string) (string, error) {
|
||||
@@ -160,81 +106,3 @@ func cleanPromptPath(name string) (string, error) {
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func cleanPromptFSPath(name string) (string, error) {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("path must not be empty")
|
||||
}
|
||||
cleaned := path.Clean(strings.TrimPrefix(trimmed, "/"))
|
||||
if cleaned == "." {
|
||||
return cleaned, nil
|
||||
}
|
||||
if !fs.ValidPath(cleaned) {
|
||||
return "", fmt.Errorf("invalid path %q", name)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
type promptFile struct {
|
||||
reader *bytes.Reader
|
||||
info promptFileInfo
|
||||
}
|
||||
|
||||
func (f *promptFile) Stat() (fs.FileInfo, error) { return f.info, nil }
|
||||
func (f *promptFile) Read(p []byte) (int, error) { return f.reader.Read(p) }
|
||||
func (f *promptFile) Close() error { return nil }
|
||||
|
||||
type promptDir struct {
|
||||
name string
|
||||
offset int
|
||||
entries []fs.DirEntry
|
||||
}
|
||||
|
||||
func (d *promptDir) Stat() (fs.FileInfo, error) { return promptFileInfo{name: d.name, dir: true}, nil }
|
||||
func (d *promptDir) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
func (d *promptDir) Close() error { return nil }
|
||||
func (d *promptDir) ReadDir(n int) ([]fs.DirEntry, error) {
|
||||
if d.offset >= len(d.entries) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
end := len(d.entries)
|
||||
if n > 0 && d.offset+n < end {
|
||||
end = d.offset + n
|
||||
}
|
||||
out := append([]fs.DirEntry(nil), d.entries[d.offset:end]...)
|
||||
d.offset = end
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type promptDirEntry struct {
|
||||
name string
|
||||
dir bool
|
||||
size int64
|
||||
}
|
||||
|
||||
func (e promptDirEntry) Name() string { return e.name }
|
||||
func (e promptDirEntry) IsDir() bool { return e.dir }
|
||||
func (e promptDirEntry) Type() fs.FileMode { return e.fileInfoMode().Type() }
|
||||
func (e promptDirEntry) Info() (fs.FileInfo, error) {
|
||||
return promptFileInfo{name: e.name, dir: e.dir, size: e.size}, nil
|
||||
}
|
||||
func (e promptDirEntry) fileInfoMode() fs.FileMode {
|
||||
if e.dir {
|
||||
return fs.ModeDir | 0o555
|
||||
}
|
||||
return 0o444
|
||||
}
|
||||
|
||||
type promptFileInfo struct {
|
||||
name string
|
||||
dir bool
|
||||
size int64
|
||||
}
|
||||
|
||||
func (i promptFileInfo) Name() string { return i.name }
|
||||
func (i promptFileInfo) Size() int64 { return i.size }
|
||||
func (i promptFileInfo) Mode() fs.FileMode { return promptDirEntry{dir: i.dir}.fileInfoMode() }
|
||||
func (i promptFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (i promptFileInfo) IsDir() bool { return i.dir }
|
||||
func (i promptFileInfo) Sys() any { return nil }
|
||||
|
||||
@@ -103,6 +103,70 @@ func TestModulePromptFSRejectsNestedSharedFileName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePromptFSRejectsDuplicateNormalizedDestinations(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
call func() error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "module",
|
||||
call: func() error {
|
||||
_, err := ModulePromptFS("module.test", fstest.MapFS{
|
||||
"assets/prompts/first.md": {Data: []byte("first")},
|
||||
"assets/prompts/second.md": {Data: []byte("second")},
|
||||
}, []ModulePromptFile{
|
||||
{Name: "task.md", Path: "assets/prompts/first.md"},
|
||||
{Name: " task.md ", Path: "assets/prompts/second.md"},
|
||||
})
|
||||
return err
|
||||
},
|
||||
want: `duplicate module prompt destination "assets/prompts/module.test/task.md"`,
|
||||
},
|
||||
{
|
||||
name: "sharedassets",
|
||||
call: func() error {
|
||||
shared := fstest.MapFS{
|
||||
"shared/first.md": {Data: []byte("first")},
|
||||
"shared/second.md": {Data: []byte("second")},
|
||||
}
|
||||
_, err := ModulePromptFS("module.test", fstest.MapFS{}, nil,
|
||||
SharedPromptFile{Name: "task.md", FS: shared, Path: "shared/first.md"},
|
||||
SharedPromptFile{Name: " task.md ", FS: shared, Path: "shared/second.md"},
|
||||
)
|
||||
return err
|
||||
},
|
||||
want: `duplicate sharedassets prompt destination "assets/prompts/module.test/sharedassets/task.md"`,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := test.call(); err == nil || err.Error() != test.want {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePromptFSAllowsMatchingModuleAndSharedBasenames(t *testing.T) {
|
||||
fsys, err := ModulePromptFS("module.test", fstest.MapFS{
|
||||
"assets/prompts/system.md": {Data: []byte("module")},
|
||||
}, []ModulePromptFile{{Name: "system.md", Path: "assets/prompts/system.md"}},
|
||||
SharedPromptFile{Name: "system.md", FS: fstest.MapFS{"shared/system.md": {Data: []byte("shared")}}, Path: "shared/system.md"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, want := range map[string]string{
|
||||
"assets/prompts/module.test/system.md": "module",
|
||||
"assets/prompts/module.test/sharedassets/system.md": "shared",
|
||||
} {
|
||||
data, err := fs.ReadFile(fsys, name)
|
||||
if err != nil || string(data) != want {
|
||||
t.Fatalf("ReadFile(%q) = %q, %v; want %q", name, data, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePromptFSRejectsInvalidModuleDir(t *testing.T) {
|
||||
_, err := ModulePromptFS(".", fstest.MapFS{}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "module prompt directory") {
|
||||
|
||||
193
internal/framework/readonlyfs/readonly_fs.go
Normal file
193
internal/framework/readonlyfs/readonly_fs.go
Normal file
@@ -0,0 +1,193 @@
|
||||
// Package readonlyfs provides an immutable filesystem backed by owned bytes.
|
||||
package readonlyfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FS is an immutable filesystem constructed from a map of file contents.
|
||||
type FS struct {
|
||||
files map[string][]byte
|
||||
dirs map[string][]fs.DirEntry
|
||||
}
|
||||
|
||||
var _ fs.FS = (*FS)(nil)
|
||||
var _ fs.ReadFileFS = (*FS)(nil)
|
||||
var _ fs.ReadDirFS = (*FS)(nil)
|
||||
|
||||
// New constructs an immutable filesystem. File names must be valid io/fs
|
||||
// paths and the root path may not be used as a file.
|
||||
func New(files map[string][]byte) (*FS, error) {
|
||||
owned := make(map[string][]byte, len(files))
|
||||
directories := map[string]struct{}{".": {}}
|
||||
for name, data := range files {
|
||||
if name == "." || !fs.ValidPath(name) {
|
||||
return nil, fmt.Errorf("invalid file path %q", name)
|
||||
}
|
||||
owned[name] = append([]byte(nil), data...)
|
||||
for directory := path.Dir(name); directory != "."; directory = path.Dir(directory) {
|
||||
directories[directory] = struct{}{}
|
||||
}
|
||||
}
|
||||
for directory := range directories {
|
||||
if _, exists := owned[directory]; exists {
|
||||
return nil, fmt.Errorf("file path %q conflicts with an inferred directory", directory)
|
||||
}
|
||||
}
|
||||
|
||||
dirs := make(map[string][]fs.DirEntry, len(directories))
|
||||
for directory := range directories {
|
||||
dirs[directory] = make([]fs.DirEntry, 0)
|
||||
}
|
||||
for directory := range directories {
|
||||
if directory == "." {
|
||||
continue
|
||||
}
|
||||
parent := path.Dir(directory)
|
||||
dirs[parent] = append(dirs[parent], dirEntry{name: path.Base(directory), dir: true})
|
||||
}
|
||||
for name, data := range owned {
|
||||
parent := path.Dir(name)
|
||||
dirs[parent] = append(dirs[parent], dirEntry{name: path.Base(name), size: int64(len(data))})
|
||||
}
|
||||
for directory := range dirs {
|
||||
sort.Slice(dirs[directory], func(left, right int) bool {
|
||||
return dirs[directory][left].Name() < dirs[directory][right].Name()
|
||||
})
|
||||
}
|
||||
return &FS{files: owned, dirs: dirs}, nil
|
||||
}
|
||||
|
||||
func (f *FS) Open(name string) (fs.File, error) {
|
||||
if err := validOpenPath(name); err != nil {
|
||||
return nil, pathError("open", name, err)
|
||||
}
|
||||
if data, ok := f.files[name]; ok {
|
||||
return &file{reader: bytes.NewReader(data), info: fileInfo{name: path.Base(name), size: int64(len(data))}}, nil
|
||||
}
|
||||
if entries, ok := f.dirs[name]; ok {
|
||||
return &dir{name: path.Base(name), entries: entries}, nil
|
||||
}
|
||||
return nil, pathError("open", name, fs.ErrNotExist)
|
||||
}
|
||||
|
||||
func (f *FS) ReadFile(name string) ([]byte, error) {
|
||||
if err := validOpenPath(name); err != nil {
|
||||
return nil, pathError("readfile", name, err)
|
||||
}
|
||||
data, ok := f.files[name]
|
||||
if !ok {
|
||||
if _, isDirectory := f.dirs[name]; isDirectory {
|
||||
return nil, pathError("readfile", name, errIsDirectory)
|
||||
}
|
||||
return nil, pathError("readfile", name, fs.ErrNotExist)
|
||||
}
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
|
||||
func (f *FS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
if err := validOpenPath(name); err != nil {
|
||||
return nil, pathError("readdir", name, err)
|
||||
}
|
||||
entries, ok := f.dirs[name]
|
||||
if !ok {
|
||||
if _, isFile := f.files[name]; isFile {
|
||||
return nil, pathError("readdir", name, errNotDirectory)
|
||||
}
|
||||
return nil, pathError("readdir", name, fs.ErrNotExist)
|
||||
}
|
||||
return append([]fs.DirEntry(nil), entries...), nil
|
||||
}
|
||||
|
||||
func validOpenPath(name string) error {
|
||||
if name == "." || fs.ValidPath(name) {
|
||||
return nil
|
||||
}
|
||||
return fs.ErrInvalid
|
||||
}
|
||||
|
||||
func pathError(operation, name string, err error) error {
|
||||
return &fs.PathError{Op: operation, Path: name, Err: err}
|
||||
}
|
||||
|
||||
var (
|
||||
errIsDirectory = errors.New("is a directory")
|
||||
errNotDirectory = errors.New("not a directory")
|
||||
)
|
||||
|
||||
type file struct {
|
||||
reader *bytes.Reader
|
||||
info fileInfo
|
||||
}
|
||||
|
||||
func (f *file) Stat() (fs.FileInfo, error) { return f.info, nil }
|
||||
func (f *file) Read(data []byte) (int, error) {
|
||||
return f.reader.Read(data)
|
||||
}
|
||||
func (f *file) Close() error { return nil }
|
||||
|
||||
type dir struct {
|
||||
name string
|
||||
offset int
|
||||
entries []fs.DirEntry
|
||||
}
|
||||
|
||||
func (d *dir) Stat() (fs.FileInfo, error) { return fileInfo{name: d.name, dir: true}, nil }
|
||||
func (d *dir) Read([]byte) (int, error) { return 0, errIsDirectory }
|
||||
func (d *dir) Close() error { return nil }
|
||||
func (d *dir) ReadDir(count int) ([]fs.DirEntry, error) {
|
||||
if count <= 0 {
|
||||
entries := append([]fs.DirEntry(nil), d.entries[d.offset:]...)
|
||||
d.offset = len(d.entries)
|
||||
return entries, nil
|
||||
}
|
||||
if d.offset >= len(d.entries) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
end := len(d.entries)
|
||||
if d.offset+count < end {
|
||||
end = d.offset + count
|
||||
}
|
||||
entries := append([]fs.DirEntry(nil), d.entries[d.offset:end]...)
|
||||
d.offset = end
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
type dirEntry struct {
|
||||
name string
|
||||
dir bool
|
||||
size int64
|
||||
}
|
||||
|
||||
func (e dirEntry) Name() string { return e.name }
|
||||
func (e dirEntry) IsDir() bool { return e.dir }
|
||||
func (e dirEntry) Type() fs.FileMode { return e.mode().Type() }
|
||||
func (e dirEntry) Info() (fs.FileInfo, error) {
|
||||
return fileInfo{name: e.name, dir: e.dir, size: e.size}, nil
|
||||
}
|
||||
func (e dirEntry) mode() fs.FileMode {
|
||||
if e.dir {
|
||||
return fs.ModeDir | 0o555
|
||||
}
|
||||
return 0o444
|
||||
}
|
||||
|
||||
type fileInfo struct {
|
||||
name string
|
||||
dir bool
|
||||
size int64
|
||||
}
|
||||
|
||||
func (i fileInfo) Name() string { return i.name }
|
||||
func (i fileInfo) Size() int64 { return i.size }
|
||||
func (i fileInfo) Mode() fs.FileMode { return dirEntry{dir: i.dir}.mode() }
|
||||
func (i fileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (i fileInfo) IsDir() bool { return i.dir }
|
||||
func (i fileInfo) Sys() any { return nil }
|
||||
153
internal/framework/readonlyfs/readonly_fs_test.go
Normal file
153
internal/framework/readonlyfs/readonly_fs_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package readonlyfs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestNewPassesFSTest(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
files map[string][]byte
|
||||
paths []string
|
||||
}{
|
||||
{name: "empty", files: map[string][]byte{}, paths: nil},
|
||||
{name: "single file", files: map[string][]byte{"message.txt": []byte("hello")}, paths: []string{"message.txt"}},
|
||||
{name: "nested tree", files: map[string][]byte{"a/one.txt": []byte("one"), "a/b/two.txt": []byte("two")}, paths: []string{"a/one.txt", "a/b/two.txt"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
fsys, err := New(test.files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fstest.TestFS(fsys, test.paths...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOwnsConstructionAndReadBytes(t *testing.T) {
|
||||
input := []byte("value")
|
||||
fsys, err := New(map[string][]byte{"value.txt": input})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input[0] = 'x'
|
||||
first, err := fsys.ReadFile("value.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first[0] = 'y'
|
||||
second, err := fsys.ReadFile("value.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(second) != "value" {
|
||||
t.Fatalf("ReadFile() = %q, want owned bytes", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesystemStandardBehavior(t *testing.T) {
|
||||
fsys, err := New(map[string][]byte{
|
||||
"z.txt": []byte("z"),
|
||||
"nested/a.md": []byte("abc"),
|
||||
"nested/b.md": []byte("b"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := fsys.ReadDir("nested")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 || entries[0].Name() != "a.md" || entries[1].Name() != "b.md" {
|
||||
t.Fatalf("ReadDir(nested) = %#v, want sorted entries", entries)
|
||||
}
|
||||
info, err := entries[0].Info()
|
||||
if err != nil || info.Mode().Perm() != 0o444 || info.Size() != 3 {
|
||||
t.Fatalf("file info = %#v, %v", info, err)
|
||||
}
|
||||
root, err := fsys.Open(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rootInfo, err := root.Stat()
|
||||
if err != nil || !rootInfo.IsDir() || rootInfo.Mode() != fs.ModeDir|0o555 {
|
||||
t.Fatalf("root info = %#v, %v", rootInfo, err)
|
||||
}
|
||||
directory := root.(fs.ReadDirFile)
|
||||
first, err := directory.ReadDir(1)
|
||||
if err != nil || len(first) != 1 || first[0].Name() != "nested" {
|
||||
t.Fatalf("first directory page = %#v, %v", first, err)
|
||||
}
|
||||
second, err := directory.ReadDir(1)
|
||||
if err != nil || len(second) != 1 || second[0].Name() != "z.txt" {
|
||||
t.Fatalf("second directory page = %#v, %v", second, err)
|
||||
}
|
||||
if _, err := directory.ReadDir(1); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("final directory page error = %v, want EOF", err)
|
||||
}
|
||||
secondRoot, err := fsys.Open(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondDirectory := secondRoot.(fs.ReadDirFile)
|
||||
firstAgain, err := secondDirectory.ReadDir(1)
|
||||
if err != nil || len(firstAgain) != 1 || firstAgain[0].Name() != "nested" {
|
||||
t.Fatalf("independent directory handle page = %#v, %v", firstAgain, err)
|
||||
}
|
||||
|
||||
left, err := fsys.Open("nested/a.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
right, err := fsys.Open("nested/a.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leftByte := make([]byte, 1)
|
||||
rightByte := make([]byte, 1)
|
||||
if _, err := left.Read(leftByte); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := right.Read(rightByte); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(leftByte) != "a" || string(rightByte) != "a" {
|
||||
t.Fatalf("independent file handles read %q and %q", leftByte, rightByte)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
op func() error
|
||||
want error
|
||||
}{
|
||||
{name: "invalid open path", op: func() error { _, err := fsys.Open("../value"); return err }, want: fs.ErrInvalid},
|
||||
{name: "missing file", op: func() error { _, err := fsys.Open("missing"); return err }, want: fs.ErrNotExist},
|
||||
{name: "read directory", op: func() error { _, err := fsys.ReadFile("nested"); return err }, want: errIsDirectory},
|
||||
{name: "read directory from file", op: func() error { _, err := fsys.ReadDir("z.txt"); return err }, want: errNotDirectory},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := test.op(); !errors.Is(err, test.want) {
|
||||
t.Fatalf("operation error = %v, want %v", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidAndConflictingPaths(t *testing.T) {
|
||||
for _, files := range []map[string][]byte{
|
||||
{".": []byte("root")},
|
||||
{"": []byte("empty")},
|
||||
{"../outside": []byte("outside")},
|
||||
{"nested//file": []byte("invalid")},
|
||||
{"file": []byte("file"), "file/nested": []byte("nested")},
|
||||
} {
|
||||
if _, err := New(files); err == nil {
|
||||
t.Fatalf("New(%#v) error = nil, want rejection", files)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -21,6 +22,7 @@ const (
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.CombatTurnList] = (*Codec)(nil)
|
||||
var _ contracts.CandidateArtifactCodec[dnd.CombatTurnList] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
@@ -97,11 +99,8 @@ func validate(value dnd.CombatTurnList) error {
|
||||
if strings.TrimSpace(ref.SourceID) == "" {
|
||||
return fmt.Errorf("%s.source_id must not be empty", refPrefix)
|
||||
}
|
||||
if ref.StartUnitID <= 0 {
|
||||
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix)
|
||||
}
|
||||
if ref.EndUnitID <= 0 {
|
||||
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
|
||||
if err := sourcerange.Validate(ref); err != nil {
|
||||
return fmt.Errorf("%s: %w", refPrefix, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,41 @@ func TestCodecCandidatePreservesValidatorOwnedValuesAndCollectionPresence(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) {
|
||||
codec := New()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*dnd.CombatTurnList)
|
||||
want string
|
||||
}{
|
||||
{name: "nonpositive start", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"},
|
||||
{name: "nonpositive end", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"},
|
||||
{name: "reversed", mutate: func(value *dnd.CombatTurnList) {
|
||||
value.CombatTurns[0].SourceRefs[0].StartUnitID = 2
|
||||
value.CombatTurns[0].SourceRefs[0].EndUnitID = 1
|
||||
}, want: "must not exceed"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := validList()
|
||||
test.mutate(&candidate)
|
||||
content, err := codec.EncodeCandidate(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||
}
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
}
|
||||
if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Encode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Decode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsRequiredShapeAndReferenceBoundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -5,11 +5,14 @@ import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
|
||||
enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -23,6 +26,7 @@ const (
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.EnemyEventList] = (*Codec)(nil)
|
||||
var _ contracts.CandidateArtifactCodec[dnd.EnemyEventList] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
@@ -50,7 +54,7 @@ func (c *Codec) Metadata(value dnd.EnemyEventList) map[string]any {
|
||||
}
|
||||
|
||||
func (c *Codec) Encode(value dnd.EnemyEventList) ([]byte, error) {
|
||||
if err := validateRequiredValueFields(value); err != nil {
|
||||
if err := validate(value); err != nil {
|
||||
return nil, fmt.Errorf("encode dnd enemy event list: %w", err)
|
||||
}
|
||||
return c.EncodeCandidate(value)
|
||||
@@ -70,6 +74,9 @@ func (c *Codec) Decode(content []byte) (dnd.EnemyEventList, error) {
|
||||
if err := validateRequiredJSONFields(content); err != nil {
|
||||
return dnd.EnemyEventList{}, fmt.Errorf("decode dnd enemy event list: %w", err)
|
||||
}
|
||||
if err := validate(value); err != nil {
|
||||
return dnd.EnemyEventList{}, fmt.Errorf("decode dnd enemy event list: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -83,13 +90,29 @@ func (c *Codec) DecodeCandidate(content []byte) (dnd.EnemyEventList, error) {
|
||||
return cloneList(value), nil
|
||||
}
|
||||
|
||||
func validateRequiredValueFields(value dnd.EnemyEventList) error {
|
||||
func validate(value dnd.EnemyEventList) error {
|
||||
if value.Events == nil {
|
||||
return fmt.Errorf("events must be present")
|
||||
}
|
||||
for index, event := range value.Events {
|
||||
if event.SourceRefs == nil {
|
||||
return fmt.Errorf("events[%d].source_refs must be present", index)
|
||||
prefix := fmt.Sprintf("events[%d]", index)
|
||||
if strings.TrimSpace(event.Name) == "" {
|
||||
return fmt.Errorf("%s.name must not be empty", prefix)
|
||||
}
|
||||
if !enemyeventmodel.SupportedKind(event.Kind) {
|
||||
return fmt.Errorf("%s.kind must be supported", prefix)
|
||||
}
|
||||
if len(event.SourceRefs) == 0 {
|
||||
return fmt.Errorf("%s.source_refs must contain at least one reference", prefix)
|
||||
}
|
||||
for refIndex, ref := range event.SourceRefs {
|
||||
refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex)
|
||||
if strings.TrimSpace(ref.SourceID) == "" {
|
||||
return fmt.Errorf("%s.source_id must not be empty", refPrefix)
|
||||
}
|
||||
if err := sourcerange.Validate(ref); err != nil {
|
||||
return fmt.Errorf("%s: %w", refPrefix, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -175,7 +198,8 @@ func cloneList(value dnd.EnemyEventList) dnd.EnemyEventList {
|
||||
for index, event := range value.Events {
|
||||
cloned.Events[index] = event
|
||||
if event.SourceRefs != nil {
|
||||
cloned.Events[index].SourceRefs = append([]source.SourceRef(nil), event.SourceRefs...)
|
||||
cloned.Events[index].SourceRefs = make([]source.SourceRef, len(event.SourceRefs))
|
||||
copy(cloned.Events[index].SourceRefs, event.SourceRefs)
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
|
||||
@@ -62,6 +62,8 @@ func TestCodecRejectsStrictJSONAndMissingRequiredFields(t *testing.T) {
|
||||
{"missing kind", strings.Replace(validJSON, `"kind":"engaged",`, "", 1), "events[0].kind must be present"},
|
||||
{"missing refs", strings.Replace(validJSON, `,"source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]`, "", 1), "events[0].source_refs must be present"},
|
||||
{"missing source ID", strings.Replace(validJSON, `"source_id":"session",`, "", 1), "source_refs[0].source_id must be present"},
|
||||
{"missing source start", strings.Replace(validJSON, `"start_unit_id":1,`, "", 1), "source_refs[0].start_unit_id must be present"},
|
||||
{"missing source end", strings.Replace(validJSON, `,"end_unit_id":1`, "", 1), "source_refs[0].end_unit_id must be present"},
|
||||
{"trailing JSON", `{"events":[]} {}`, "multiple JSON values"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
@@ -73,7 +75,7 @@ func TestCodecRejectsStrictJSONAndMissingRequiredFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecDefensivelyOwnsValuesAndDefersSemanticValidation(t *testing.T) {
|
||||
func TestCodecDefensivelyOwnsCandidateValues(t *testing.T) {
|
||||
codec := New()
|
||||
candidate := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{
|
||||
Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{SourceID: "", StartUnitID: 0, EndUnitID: -1}},
|
||||
@@ -82,13 +84,16 @@ func TestCodecDefensivelyOwnsValuesAndDefersSemanticValidation(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := codec.Decode(content)
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("Decode() = %#v, %v; want semantic candidate preservation", decoded, err)
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want semantic candidate preservation", decoded, err)
|
||||
}
|
||||
decoded.Events[0].SourceRefs[0].SourceID = "changed"
|
||||
if candidate.Events[0].SourceRefs[0].SourceID != "" {
|
||||
t.Fatal("Decode() retained caller-owned source references")
|
||||
t.Fatal("DecodeCandidate() retained caller-owned source references")
|
||||
}
|
||||
if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), "name must not be empty") {
|
||||
t.Fatalf("Decode() error = %v, want durable value validation", err)
|
||||
}
|
||||
|
||||
first := codec.Schema()
|
||||
@@ -97,3 +102,57 @@ func TestCodecDefensivelyOwnsValuesAndDefersSemanticValidation(t *testing.T) {
|
||||
t.Fatal("Schema() returned shared bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsInvalidDurableValuesWhileCandidatesPreserveThem(t *testing.T) {
|
||||
codec := New()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*dnd.EnemyEventList)
|
||||
want string
|
||||
}{
|
||||
{name: "blank name", mutate: func(value *dnd.EnemyEventList) { value.Events[0].Name = " " }, want: "name must not be empty"},
|
||||
{name: "unsupported kind", mutate: func(value *dnd.EnemyEventList) { value.Events[0].Kind = "unsupported" }, want: "kind must be supported"},
|
||||
{name: "empty source refs", mutate: func(value *dnd.EnemyEventList) { value.Events[0].SourceRefs = []source.SourceRef{} }, want: "source_refs must contain"},
|
||||
{name: "blank source ID", mutate: func(value *dnd.EnemyEventList) { value.Events[0].SourceRefs[0].SourceID = " " }, want: "source_id must not be empty"},
|
||||
{name: "nonpositive start", mutate: func(value *dnd.EnemyEventList) { value.Events[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"},
|
||||
{name: "nonpositive end", mutate: func(value *dnd.EnemyEventList) { value.Events[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"},
|
||||
{name: "reversed", mutate: func(value *dnd.EnemyEventList) {
|
||||
value.Events[0].SourceRefs[0].StartUnitID = 2
|
||||
value.Events[0].SourceRefs[0].EndUnitID = 1
|
||||
}, want: "must not exceed"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{
|
||||
Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
test.mutate(&candidate)
|
||||
content, err := codec.EncodeCandidate(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||
}
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
}
|
||||
if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Encode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Decode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecAcceptsEmptyDurableEventList(t *testing.T) {
|
||||
value := dnd.EnemyEventList{Events: []dnd.EnemyEvent{}}
|
||||
content, err := New().Encode(value)
|
||||
if err != nil || string(content) != `{"events":[]}` {
|
||||
t.Fatalf("Encode() = %s, %v", content, err)
|
||||
}
|
||||
decoded, err := New().Decode(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, value) {
|
||||
t.Fatalf("Decode() = %#v, %v; want %#v", decoded, err, value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/itemoccurrences"
|
||||
)
|
||||
|
||||
@@ -24,6 +25,7 @@ const (
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.ItemOccurrenceList] = (*Codec)(nil)
|
||||
var _ contracts.CandidateArtifactCodec[dnd.ItemOccurrenceList] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
@@ -119,11 +121,8 @@ func validate(value dnd.ItemOccurrenceList) error {
|
||||
if strings.TrimSpace(ref.SourceID) == "" {
|
||||
return fmt.Errorf("%s.source_id must not be empty", refPrefix)
|
||||
}
|
||||
if ref.StartUnitID <= 0 {
|
||||
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix)
|
||||
}
|
||||
if ref.EndUnitID <= 0 {
|
||||
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
|
||||
if err := sourcerange.Validate(ref); err != nil {
|
||||
return fmt.Errorf("%s: %w", refPrefix, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,41 @@ func TestCodecSupportsEmptyListAndPreservesInvalidCandidates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) {
|
||||
codec := New()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*dnd.ItemOccurrenceList)
|
||||
want string
|
||||
}{
|
||||
{name: "nonpositive start", mutate: func(value *dnd.ItemOccurrenceList) { value.Occurrences[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"},
|
||||
{name: "nonpositive end", mutate: func(value *dnd.ItemOccurrenceList) { value.Occurrences[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"},
|
||||
{name: "reversed", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||
value.Occurrences[0].SourceRefs[0].StartUnitID = 2
|
||||
value.Occurrences[0].SourceRefs[0].EndUnitID = 1
|
||||
}, want: "must not exceed"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := validList()
|
||||
test.mutate(&candidate)
|
||||
content, err := codec.EncodeCandidate(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||
}
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
}
|
||||
if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Encode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Decode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsStrictJSONAndApprovedBoundaries(t *testing.T) {
|
||||
validJSON := `{"occurrences":[{"item_id":"ring","name":"Ring","kind":"acquired","to":"party","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||
tests := []struct {
|
||||
@@ -124,14 +159,29 @@ func TestCodecDeepCopiesBoundaryValuesAndMetadata(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sibling, err := codec.DecodeCandidate(content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Occurrences[1].Quantity == value.Occurrences[1].Quantity || &decoded.Occurrences[1].SourceRefs[0] == &value.Occurrences[1].SourceRefs[0] {
|
||||
t.Fatal("DecodeCandidate() retained caller-owned occurrence fields")
|
||||
}
|
||||
nameOffset := bytes.Index(content, []byte("Gold Pieces"))
|
||||
if nameOffset < 0 {
|
||||
t.Fatal("candidate JSON does not contain item name")
|
||||
}
|
||||
content[nameOffset] = 'X'
|
||||
if decoded.Occurrences[1].Name != "Gold Pieces" {
|
||||
t.Fatal("DecodeCandidate() retained input bytes")
|
||||
}
|
||||
*decoded.Occurrences[1].Quantity = 99
|
||||
decoded.Occurrences[1].SourceRefs[0].SourceID = "changed"
|
||||
if *value.Occurrences[1].Quantity != 12 || value.Occurrences[1].SourceRefs[0].SourceID != "session" {
|
||||
t.Fatal("decoded item occurrence aliases input")
|
||||
}
|
||||
if *sibling.Occurrences[1].Quantity != 12 || sibling.Occurrences[1].SourceRefs[0].SourceID != "session" {
|
||||
t.Fatal("decoded item occurrence aliases a sibling decode")
|
||||
}
|
||||
|
||||
first := codec.Schema()
|
||||
first.JSONSchema[0] = '['
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
|
||||
)
|
||||
|
||||
@@ -23,6 +24,7 @@ const (
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.ItemRegistry] = (*Codec)(nil)
|
||||
var _ contracts.CandidateArtifactCodec[dnd.ItemRegistry] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
@@ -99,11 +101,8 @@ func validate(value dnd.ItemRegistry) error {
|
||||
if strings.TrimSpace(ref.SourceID) == "" {
|
||||
return fmt.Errorf("%s.source_id must not be empty", refPrefix)
|
||||
}
|
||||
if ref.StartUnitID <= 0 {
|
||||
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix)
|
||||
}
|
||||
if ref.EndUnitID <= 0 {
|
||||
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
|
||||
if err := sourcerange.Validate(ref); err != nil {
|
||||
return fmt.Errorf("%s: %w", refPrefix, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,9 +59,13 @@ func TestCodecOwnsDurableSchemaAndExactType(t *testing.T) {
|
||||
func TestCodecCandidateEncodingMetadataAndStrictDecode(t *testing.T) {
|
||||
codec := New()
|
||||
candidate := dnd.ItemRegistry{Items: []dnd.Item{{Name: "Silver Key"}}}
|
||||
if content, err := codec.EncodeCandidate(candidate); err != nil || string(content) != `{"items":[{"id":"","name":"Silver Key","source_refs":null}]}` {
|
||||
content, err := codec.EncodeCandidate(candidate)
|
||||
if err != nil || string(content) != `{"items":[{"id":"","name":"Silver Key","source_refs":null}]}` {
|
||||
t.Fatalf("EncodeCandidate() = %s, %v", content, err)
|
||||
}
|
||||
if decoded, err := codec.DecodeCandidate(content); err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
}
|
||||
if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), "item ID pattern") {
|
||||
t.Fatalf("Encode() error = %v, want durable validation", err)
|
||||
}
|
||||
@@ -80,6 +84,74 @@ func TestCodecCandidateEncodingMetadataAndStrictDecode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecCandidateDecodeOwnsValues(t *testing.T) {
|
||||
codec := New()
|
||||
input := validRegistry()
|
||||
content, err := codec.EncodeCandidate(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := codec.DecodeCandidate(content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := codec.DecodeCandidate(content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nameOffset := bytes.Index(content, []byte("Silver Key"))
|
||||
if nameOffset < 0 {
|
||||
t.Fatal("candidate JSON does not contain item name")
|
||||
}
|
||||
content[nameOffset] = 'X'
|
||||
if first.Items[0].Name != "Silver Key" {
|
||||
t.Fatal("DecodeCandidate() retained input bytes")
|
||||
}
|
||||
first.Items[0].Name = "changed"
|
||||
first.Items[0].SourceRefs[0].SourceID = "changed"
|
||||
if input.Items[0].Name != "Silver Key" || input.Items[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||
t.Fatal("DecodeCandidate() retained input values")
|
||||
}
|
||||
if second.Items[0].Name != "Silver Key" || second.Items[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||
t.Fatal("DecodeCandidate() returned aliased values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) {
|
||||
codec := New()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*dnd.ItemRegistry)
|
||||
want string
|
||||
}{
|
||||
{name: "nonpositive start", mutate: func(value *dnd.ItemRegistry) { value.Items[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"},
|
||||
{name: "nonpositive end", mutate: func(value *dnd.ItemRegistry) { value.Items[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"},
|
||||
{name: "reversed", mutate: func(value *dnd.ItemRegistry) {
|
||||
value.Items[0].SourceRefs[0].StartUnitID = 2
|
||||
value.Items[0].SourceRefs[0].EndUnitID = 1
|
||||
}, want: "must not exceed"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := validRegistry()
|
||||
test.mutate(&candidate)
|
||||
content, err := codec.EncodeCandidate(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||
}
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
}
|
||||
if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Encode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Decode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
|
||||
codec := New()
|
||||
first := codec.Schema()
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
|
||||
)
|
||||
|
||||
@@ -23,6 +24,7 @@ const (
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.LocationOccurrenceList] = (*Codec)(nil)
|
||||
var _ contracts.CandidateArtifactCodec[dnd.LocationOccurrenceList] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
@@ -102,11 +104,8 @@ func validate(value dnd.LocationOccurrenceList) error {
|
||||
if strings.TrimSpace(ref.SourceID) == "" {
|
||||
return fmt.Errorf("%s.source_id must not be empty", refPrefix)
|
||||
}
|
||||
if ref.StartUnitID <= 0 {
|
||||
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix)
|
||||
}
|
||||
if ref.EndUnitID <= 0 {
|
||||
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
|
||||
if err := sourcerange.Validate(ref); err != nil {
|
||||
return fmt.Errorf("%s: %w", refPrefix, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,74 @@ func TestCodecSupportsEmptyListsAndCandidateSemanticFailures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecCandidateDecodeOwnsValues(t *testing.T) {
|
||||
codec := New()
|
||||
input := validList()
|
||||
content, err := codec.EncodeCandidate(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := codec.DecodeCandidate(content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := codec.DecodeCandidate(content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nameOffset := bytes.Index(content, []byte("The Old Tavern"))
|
||||
if nameOffset < 0 {
|
||||
t.Fatal("candidate JSON does not contain location name")
|
||||
}
|
||||
content[nameOffset] = 'X'
|
||||
if first.Occurrences[0].Name != "The Old Tavern" {
|
||||
t.Fatal("DecodeCandidate() retained input bytes")
|
||||
}
|
||||
first.Occurrences[0].Name = "changed"
|
||||
first.Occurrences[0].SourceRefs[0].SourceID = "changed"
|
||||
if input.Occurrences[0].Name != "The Old Tavern" || input.Occurrences[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||
t.Fatal("DecodeCandidate() retained input values")
|
||||
}
|
||||
if second.Occurrences[0].Name != "The Old Tavern" || second.Occurrences[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||
t.Fatal("DecodeCandidate() returned aliased values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) {
|
||||
codec := New()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*dnd.LocationOccurrenceList)
|
||||
want string
|
||||
}{
|
||||
{name: "nonpositive start", mutate: func(value *dnd.LocationOccurrenceList) { value.Occurrences[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"},
|
||||
{name: "nonpositive end", mutate: func(value *dnd.LocationOccurrenceList) { value.Occurrences[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"},
|
||||
{name: "reversed", mutate: func(value *dnd.LocationOccurrenceList) {
|
||||
value.Occurrences[0].SourceRefs[0].StartUnitID = 2
|
||||
value.Occurrences[0].SourceRefs[0].EndUnitID = 1
|
||||
}, want: "must not exceed"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := validList()
|
||||
test.mutate(&candidate)
|
||||
content, err := codec.EncodeCandidate(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||
}
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
}
|
||||
if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Encode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Decode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsStructuralJSONBeforeSemanticApproval(t *testing.T) {
|
||||
valid := `{"occurrences":[{"location_id":"location:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"The Tavern","kind":"visited","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||
for _, test := range []struct{ name, raw, want string }{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
|
||||
)
|
||||
|
||||
@@ -23,6 +24,7 @@ const (
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.LocationRegistry] = (*Codec)(nil)
|
||||
var _ contracts.CandidateArtifactCodec[dnd.LocationRegistry] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
@@ -99,11 +101,8 @@ func validate(value dnd.LocationRegistry) error {
|
||||
if strings.TrimSpace(ref.SourceID) == "" {
|
||||
return fmt.Errorf("%s.source_id must not be empty", refPrefix)
|
||||
}
|
||||
if ref.StartUnitID <= 0 {
|
||||
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix)
|
||||
}
|
||||
if ref.EndUnitID <= 0 {
|
||||
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
|
||||
if err := sourcerange.Validate(ref); err != nil {
|
||||
return fmt.Errorf("%s: %w", refPrefix, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,74 @@ func TestCodecSupportsEmptyListsAndCandidateSemanticFailures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecCandidateDecodeOwnsValues(t *testing.T) {
|
||||
codec := New()
|
||||
input := validList()
|
||||
content, err := codec.EncodeCandidate(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := codec.DecodeCandidate(content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := codec.DecodeCandidate(content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nameOffset := bytes.Index(content, []byte("The Old Tavern"))
|
||||
if nameOffset < 0 {
|
||||
t.Fatal("candidate JSON does not contain location name")
|
||||
}
|
||||
content[nameOffset] = 'X'
|
||||
if first.Locations[0].Name != "The Old Tavern" {
|
||||
t.Fatal("DecodeCandidate() retained input bytes")
|
||||
}
|
||||
first.Locations[0].Name = "changed"
|
||||
first.Locations[0].SourceRefs[0].SourceID = "changed"
|
||||
if input.Locations[0].Name != "The Old Tavern" || input.Locations[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||
t.Fatal("DecodeCandidate() retained input values")
|
||||
}
|
||||
if second.Locations[0].Name != "The Old Tavern" || second.Locations[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||
t.Fatal("DecodeCandidate() returned aliased values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) {
|
||||
codec := New()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*dnd.LocationRegistry)
|
||||
want string
|
||||
}{
|
||||
{name: "nonpositive start", mutate: func(value *dnd.LocationRegistry) { value.Locations[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"},
|
||||
{name: "nonpositive end", mutate: func(value *dnd.LocationRegistry) { value.Locations[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"},
|
||||
{name: "reversed", mutate: func(value *dnd.LocationRegistry) {
|
||||
value.Locations[0].SourceRefs[0].StartUnitID = 2
|
||||
value.Locations[0].SourceRefs[0].EndUnitID = 1
|
||||
}, want: "must not exceed"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := validList()
|
||||
test.mutate(&candidate)
|
||||
content, err := codec.EncodeCandidate(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||
}
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
}
|
||||
if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Encode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Decode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsStructuralJSONBeforeSemanticApproval(t *testing.T) {
|
||||
valid := `{"locations":[{"id":"location:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"The Tavern","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||
for _, test := range []struct{ name, raw, want string }{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -22,6 +23,7 @@ const (
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.NPCOccurrenceList] = (*Codec)(nil)
|
||||
var _ contracts.CandidateArtifactCodec[dnd.NPCOccurrenceList] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
@@ -101,11 +103,8 @@ func validate(value dnd.NPCOccurrenceList) error {
|
||||
if strings.TrimSpace(ref.SourceID) == "" {
|
||||
return fmt.Errorf("%s.source_id must not be empty", refPrefix)
|
||||
}
|
||||
if ref.StartUnitID <= 0 {
|
||||
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix)
|
||||
}
|
||||
if ref.EndUnitID <= 0 {
|
||||
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
|
||||
if err := sourcerange.Validate(ref); err != nil {
|
||||
return fmt.Errorf("%s: %w", refPrefix, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,74 @@ func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecCandidateDecodeOwnsValues(t *testing.T) {
|
||||
codec := New()
|
||||
input := validList()
|
||||
content, err := codec.EncodeCandidate(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := codec.DecodeCandidate(content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := codec.DecodeCandidate(content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nameOffset := bytes.Index(content, []byte("Mira Thorn"))
|
||||
if nameOffset < 0 {
|
||||
t.Fatal("candidate JSON does not contain NPC name")
|
||||
}
|
||||
content[nameOffset] = 'X'
|
||||
if first.Occurrences[0].Name != "Mira Thorn" {
|
||||
t.Fatal("DecodeCandidate() retained input bytes")
|
||||
}
|
||||
first.Occurrences[0].Name = "changed"
|
||||
first.Occurrences[0].SourceRefs[0].SourceID = "changed"
|
||||
if input.Occurrences[0].Name != "Mira Thorn" || input.Occurrences[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||
t.Fatal("DecodeCandidate() retained input values")
|
||||
}
|
||||
if second.Occurrences[0].Name != "Mira Thorn" || second.Occurrences[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||
t.Fatal("DecodeCandidate() returned aliased values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) {
|
||||
codec := New()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*dnd.NPCOccurrenceList)
|
||||
want string
|
||||
}{
|
||||
{name: "nonpositive start", mutate: func(value *dnd.NPCOccurrenceList) { value.Occurrences[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"},
|
||||
{name: "nonpositive end", mutate: func(value *dnd.NPCOccurrenceList) { value.Occurrences[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"},
|
||||
{name: "reversed", mutate: func(value *dnd.NPCOccurrenceList) {
|
||||
value.Occurrences[0].SourceRefs[0].StartUnitID = 2
|
||||
value.Occurrences[0].SourceRefs[0].EndUnitID = 1
|
||||
}, want: "must not exceed"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := validList()
|
||||
test.mutate(&candidate)
|
||||
content, err := codec.EncodeCandidate(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||
}
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
}
|
||||
if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Encode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Decode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) {
|
||||
validJSON := `{"occurrences":[{"npc_id":"npc:test-mira","name":"Mira Thorn","kind":"dialogue","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||
for _, test := range []struct{ name, raw, want string }{
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,7 @@ const (
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.NPCRegistry] = (*Codec)(nil)
|
||||
var _ contracts.CandidateArtifactCodec[dnd.NPCRegistry] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
@@ -98,11 +100,8 @@ func validate(value dnd.NPCRegistry) error {
|
||||
if strings.TrimSpace(ref.SourceID) == "" {
|
||||
return fmt.Errorf("%s.source_id must not be empty", refPrefix)
|
||||
}
|
||||
if ref.StartUnitID <= 0 {
|
||||
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix)
|
||||
}
|
||||
if ref.EndUnitID <= 0 {
|
||||
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
|
||||
if err := sourcerange.Validate(ref); err != nil {
|
||||
return fmt.Errorf("%s: %w", refPrefix, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,74 @@ func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) {
|
||||
codec := New()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*dnd.NPCRegistry)
|
||||
want string
|
||||
}{
|
||||
{name: "nonpositive start", mutate: func(value *dnd.NPCRegistry) { value.NPCs[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"},
|
||||
{name: "nonpositive end", mutate: func(value *dnd.NPCRegistry) { value.NPCs[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"},
|
||||
{name: "reversed", mutate: func(value *dnd.NPCRegistry) {
|
||||
value.NPCs[0].SourceRefs[0].StartUnitID = 2
|
||||
value.NPCs[0].SourceRefs[0].EndUnitID = 1
|
||||
}, want: "must not exceed"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := validList()
|
||||
test.mutate(&candidate)
|
||||
content, err := codec.EncodeCandidate(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||
}
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
}
|
||||
if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Encode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Decode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecCandidateDecodeOwnsValues(t *testing.T) {
|
||||
codec := New()
|
||||
input := validList()
|
||||
content, err := codec.EncodeCandidate(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := codec.DecodeCandidate(content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := codec.DecodeCandidate(content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nameOffset := bytes.Index(content, []byte("Mira Thorn"))
|
||||
if nameOffset < 0 {
|
||||
t.Fatal("candidate JSON does not contain NPC name")
|
||||
}
|
||||
content[nameOffset] = 'X'
|
||||
if first.NPCs[0].Name != "Mira Thorn" {
|
||||
t.Fatal("DecodeCandidate() retained input bytes")
|
||||
}
|
||||
first.NPCs[0].Name = "changed"
|
||||
first.NPCs[0].SourceRefs[0].SourceID = "changed"
|
||||
if input.NPCs[0].Name != "Mira Thorn" || input.NPCs[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||
t.Fatal("DecodeCandidate() retained input values")
|
||||
}
|
||||
if second.NPCs[0].Name != "Mira Thorn" || second.NPCs[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||
t.Fatal("DecodeCandidate() returned aliased values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsEveryRequiredShapeBoundary(t *testing.T) {
|
||||
base := validList().NPCs[0]
|
||||
tests := []struct {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -21,6 +22,7 @@ const (
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.SceneDescriptionList] = (*Codec)(nil)
|
||||
var _ contracts.CandidateArtifactCodec[dnd.SceneDescriptionList] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
@@ -101,11 +103,8 @@ func validate(value dnd.SceneDescriptionList) error {
|
||||
if strings.TrimSpace(scene.SourceRef.SourceID) == "" {
|
||||
return fmt.Errorf("%s.source_ref.source_id must not be empty", prefix)
|
||||
}
|
||||
if scene.SourceRef.StartUnitID <= 0 {
|
||||
return fmt.Errorf("%s.source_ref.start_unit_id must be positive", prefix)
|
||||
}
|
||||
if scene.SourceRef.EndUnitID <= 0 {
|
||||
return fmt.Errorf("%s.source_ref.end_unit_id must be positive", prefix)
|
||||
if err := sourcerange.Validate(scene.SourceRef); err != nil {
|
||||
return fmt.Errorf("%s.source_ref: %w", prefix, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -168,6 +168,41 @@ func TestCodecCandidatePreservesValidatorOwnedValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) {
|
||||
codec := New()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*dnd.SceneDescriptionList)
|
||||
want string
|
||||
}{
|
||||
{name: "nonpositive start", mutate: func(value *dnd.SceneDescriptionList) { value.Scenes[0].SourceRef.StartUnitID = 0 }, want: "start_unit_id"},
|
||||
{name: "nonpositive end", mutate: func(value *dnd.SceneDescriptionList) { value.Scenes[0].SourceRef.EndUnitID = 0 }, want: "end_unit_id"},
|
||||
{name: "reversed", mutate: func(value *dnd.SceneDescriptionList) {
|
||||
value.Scenes[0].SourceRef.StartUnitID = 2
|
||||
value.Scenes[0].SourceRef.EndUnitID = 1
|
||||
}, want: "must not exceed"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := validList()
|
||||
test.mutate(&candidate)
|
||||
content, err := codec.EncodeCandidate(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||
}
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
}
|
||||
if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Encode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Decode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsRequiredApprovedValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
24
internal/modules/dnd/codec/sourcerange/sourcerange.go
Normal file
24
internal/modules/dnd/codec/sourcerange/sourcerange.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Package sourcerange validates durable D&D source-reference ranges.
|
||||
package sourcerange
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
// Validate checks the source-independent numeric constraints of a durable
|
||||
// source reference. Source membership and extraction-chunk coverage require
|
||||
// source context and remain the responsibility of source-aware validators.
|
||||
func Validate(ref source.SourceRef) error {
|
||||
if ref.StartUnitID <= 0 {
|
||||
return fmt.Errorf("start_unit_id must be positive")
|
||||
}
|
||||
if ref.EndUnitID <= 0 {
|
||||
return fmt.Errorf("end_unit_id must be positive")
|
||||
}
|
||||
if ref.StartUnitID > ref.EndUnitID {
|
||||
return fmt.Errorf("start_unit_id must not exceed end_unit_id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
31
internal/modules/dnd/codec/sourcerange/sourcerange_test.go
Normal file
31
internal/modules/dnd/codec/sourcerange/sourcerange_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package sourcerange
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
func TestValidateChecksOnlyDurableNumericRange(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ref source.SourceRef
|
||||
want string
|
||||
}{
|
||||
{name: "valid without source ID", ref: source.SourceRef{StartUnitID: 1, EndUnitID: 2}},
|
||||
{name: "nonpositive start", ref: source.SourceRef{StartUnitID: 0, EndUnitID: 1}, want: "start_unit_id"},
|
||||
{name: "nonpositive end", ref: source.SourceRef{StartUnitID: 1, EndUnitID: 0}, want: "end_unit_id"},
|
||||
{name: "reversed", ref: source.SourceRef{StartUnitID: 2, EndUnitID: 1}, want: "must not exceed"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := Validate(test.ref)
|
||||
if test.want == "" && err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if test.want != "" && (err == nil || !strings.Contains(err.Error(), test.want)) {
|
||||
t.Fatalf("Validate() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -21,6 +22,7 @@ const (
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.SpellList] = (*Codec)(nil)
|
||||
var _ contracts.CandidateArtifactCodec[dnd.SpellList] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
@@ -90,11 +92,8 @@ func validate(value dnd.SpellList) error {
|
||||
if strings.TrimSpace(ref.SourceID) == "" {
|
||||
return fmt.Errorf("spell_casts[%d].source_refs[%d].source_id must not be empty", index, refIndex)
|
||||
}
|
||||
if ref.StartUnitID <= 0 {
|
||||
return fmt.Errorf("spell_casts[%d].source_refs[%d].start_unit_id must be positive", index, refIndex)
|
||||
}
|
||||
if ref.EndUnitID <= 0 {
|
||||
return fmt.Errorf("spell_casts[%d].source_refs[%d].end_unit_id must be positive", index, refIndex)
|
||||
if err := sourcerange.Validate(ref); err != nil {
|
||||
return fmt.Errorf("spell_casts[%d].source_refs[%d]: %w", index, refIndex, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,10 @@ func TestCodecEncodesIncompleteCandidateWithoutWeakeningFinalEncoding(t *testing
|
||||
if !json.Valid(content) {
|
||||
t.Fatalf("EncodeCandidate() = %q, want JSON", content)
|
||||
}
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
}
|
||||
result, err := spellshape.New(spellshape.Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Value: candidate})
|
||||
if err != nil || result.Approved || result.ReasonCode != spellshape.ReasonCode {
|
||||
t.Fatalf("shape validation = %#v, %v; want candidate rejection", result, err)
|
||||
@@ -121,6 +125,44 @@ func TestCodecEncodesIncompleteCandidateWithoutWeakeningFinalEncoding(t *testing
|
||||
if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), "spell_casts must be present") {
|
||||
t.Fatalf("Encode() error = %v, want strict final shape error", err)
|
||||
}
|
||||
if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), "spell_casts must be present") {
|
||||
t.Fatalf("Decode() error = %v, want strict final shape error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) {
|
||||
codec := New()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*dnd.SpellList)
|
||||
want string
|
||||
}{
|
||||
{name: "nonpositive start", mutate: func(value *dnd.SpellList) { value.SpellCasts[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"},
|
||||
{name: "nonpositive end", mutate: func(value *dnd.SpellList) { value.SpellCasts[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"},
|
||||
{name: "reversed", mutate: func(value *dnd.SpellList) {
|
||||
value.SpellCasts[0].SourceRefs[0].StartUnitID = 2
|
||||
value.SpellCasts[0].SourceRefs[0].EndUnitID = 1
|
||||
}, want: "must not exceed"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := dnd.SpellList{SpellCasts: []dnd.SpellCast{{Caster: "Aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}
|
||||
test.mutate(&candidate)
|
||||
content, err := codec.EncodeCandidate(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||
}
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
}
|
||||
if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Encode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Decode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecSchemaIsMutationSafe(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
package enemyevents
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
@@ -58,9 +60,49 @@ func SourceRefsEqual(order shared.SourceRefOrder, left, right []source.SourceRef
|
||||
// ExactEqual reports whether events have the same subject identity, kind, and
|
||||
// complete canonical evidence sequence.
|
||||
func ExactEqual(order shared.SourceRefOrder, left, right dnd.EnemyEvent) bool {
|
||||
left.SourceRefs = order.Canonicalize(left.SourceRefs)
|
||||
right.SourceRefs = order.Canonicalize(right.SourceRefs)
|
||||
return CanonicalExactEqual(left, right)
|
||||
}
|
||||
|
||||
type canonicalIdentity struct {
|
||||
Subject string `json:"subject"`
|
||||
Kind dnd.EnemyEventKind `json:"kind"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
}
|
||||
|
||||
// CanonicalIdentity returns the complete duplicate-lookup key for an event
|
||||
// whose source references are already canonical.
|
||||
func CanonicalIdentity(event dnd.EnemyEvent) string {
|
||||
encoded, err := json.Marshal(canonicalIdentity{
|
||||
Subject: ComparisonKey(event.Name),
|
||||
Kind: event.Kind,
|
||||
SourceRefs: event.SourceRefs,
|
||||
})
|
||||
if err != nil {
|
||||
panic("encode enemy event identity")
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
// CanonicalExactEqual reports whether events have the same complete duplicate
|
||||
// identity without modifying or canonicalizing their source references.
|
||||
func CanonicalExactEqual(left, right dnd.EnemyEvent) bool {
|
||||
return ComparisonKey(left.Name) == ComparisonKey(right.Name) &&
|
||||
left.Kind == right.Kind &&
|
||||
SourceRefsEqual(order, left.SourceRefs, right.SourceRefs)
|
||||
canonicalSourceRefsEqual(left.SourceRefs, right.SourceRefs)
|
||||
}
|
||||
|
||||
func canonicalSourceRefsEqual(left, right []source.SourceRef) bool {
|
||||
if (left == nil) != (right == nil) || len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Less defines the canonical stable event order. Invalid source references
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestExtractUsesDocumentOrderForReferencesAndTurns(t *testing.T) {
|
||||
req := extractionRequest(t)
|
||||
req.Source.Units = []source.SourceUnit{{ID: 30}, {ID: 10}}
|
||||
req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...)
|
||||
req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 10}
|
||||
req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 10, EndUnitID: 30}
|
||||
req.References = sceneReferences(t, req.Chunk, dnd.SceneKindCombat)
|
||||
|
||||
result, err := newExtractor(t, client).Extract(context.Background(), req)
|
||||
@@ -265,7 +265,7 @@ func TestExtractAppliesSceneEligibilityBeforePromptConstruction(t *testing.T) {
|
||||
name: "start mismatch",
|
||||
scenes: []dnd.SceneDescription{func() dnd.SceneDescription {
|
||||
value := sceneDescription(extractionRequest(t).Chunk, dnd.SceneKindCombat)
|
||||
value.SourceRef.StartUnitID = 2
|
||||
value.SourceRef.StartUnitID = 3
|
||||
return value
|
||||
}()},
|
||||
wantWarning: "scene_classification_unavailable",
|
||||
@@ -275,7 +275,7 @@ func TestExtractAppliesSceneEligibilityBeforePromptConstruction(t *testing.T) {
|
||||
name: "end mismatch",
|
||||
scenes: []dnd.SceneDescription{func() dnd.SceneDescription {
|
||||
value := sceneDescription(extractionRequest(t).Chunk, dnd.SceneKindCombat)
|
||||
value.SourceRef.EndUnitID = 10
|
||||
value.SourceRef.EndUnitID = 9
|
||||
return value
|
||||
}()},
|
||||
wantWarning: "scene_classification_unavailable",
|
||||
@@ -340,7 +340,7 @@ func TestSceneEligibilityMetadataAndFingerprintsTrackGatingValues(t *testing.T)
|
||||
change func(*dnd.SceneDescription)
|
||||
}{
|
||||
{"kind", func(value *dnd.SceneDescription) { value.Kind = dnd.SceneKindNarrative }},
|
||||
{"range", func(value *dnd.SceneDescription) { value.SourceRef.StartUnitID = 2 }},
|
||||
{"range", func(value *dnd.SceneDescription) { value.SourceRef.StartUnitID = 3 }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
changed := base
|
||||
@@ -477,7 +477,7 @@ func extractionRequest(t *testing.T) contracts.TypedExtractionRequest {
|
||||
ID: "session-alpha:chunk:0",
|
||||
SourceID: doc.ID,
|
||||
Index: 0,
|
||||
Ref: source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 2},
|
||||
Ref: source.SourceRef{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 10},
|
||||
Content: []byte(`{"units":[10,2]}`),
|
||||
MediaType: "application/json",
|
||||
Units: append([]source.SourceUnit(nil), doc.Units...),
|
||||
|
||||
@@ -179,6 +179,7 @@ func TestGroundingRejectsMissingAndInvalidReferences(t *testing.T) {
|
||||
{"malformed durable content", replaceItem(valid, NPCOccurrenceReferenceSlot, contracts.ReferenceItem{MediaType: "application/json", Content: []byte(`{}`)})},
|
||||
{"wrong media type", replaceItem(valid, CombatTurnReferenceSlot, contracts.ReferenceItem{MediaType: "text/plain", Content: valid.Slots[CombatTurnReferenceSlot].Items[0].Content})},
|
||||
{"oversize", replaceItem(valid, CombatTurnReferenceSlot, contracts.ReferenceItem{MediaType: "application/json", Content: make([]byte, ReferenceMaxBytes+1)})},
|
||||
{"reversed combat-turn evidence", reversedCombatTurnReference(t, valid)},
|
||||
}
|
||||
resolver, err := newGroundingResolver(valid)
|
||||
if err != nil {
|
||||
@@ -193,6 +194,19 @@ func TestGroundingRejectsMissingAndInvalidReferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func reversedCombatTurnReference(t *testing.T, references contracts.ReferenceSet) contracts.ReferenceSet {
|
||||
t.Helper()
|
||||
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Ashfang", TurnKind: dnd.CombatTurnKindTurn,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 1}},
|
||||
}}}
|
||||
content, err := combatturncodec.New().EncodeCandidate(value)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||
}
|
||||
return replaceItem(references, CombatTurnReferenceSlot, newReferenceItem(CombatTurnReferenceSlot, content))
|
||||
}
|
||||
|
||||
func groundingReferences(t *testing.T, enemy string, sceneKind dnd.SceneKind) contracts.ReferenceSet {
|
||||
t.Helper()
|
||||
npcContent, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: []dnd.NPC{{
|
||||
|
||||
@@ -64,7 +64,7 @@ func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID s
|
||||
}
|
||||
values := make([]source.SourceRef, len(refs))
|
||||
for index, ref := range refs {
|
||||
values[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartSegment, EndUnitID: ref.EndSegment}
|
||||
values[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) {
|
||||
if len(result.Value.Occurrences) != 1 || result.Value.Occurrences[0].ItemID != id || result.Value.Occurrences[0].Name != "Torch" {
|
||||
t.Fatalf("occurrences = %#v", result.Value.Occurrences)
|
||||
}
|
||||
if refs := result.Value.Occurrences[0].SourceRefs; len(refs) != 1 || refs[0].SourceID != req.Source.ID || refs[0].StartUnitID != 1 || refs[0].EndUnitID != 1 {
|
||||
t.Fatalf("occurrence evidence = %#v, want current-source unit range", refs)
|
||||
}
|
||||
input := client.requests[0].Inputs[ItemRegistryReferenceSlot]
|
||||
if input.Name != ItemRegistryReferenceSlot || string(input.Content) != `{"items":[{"name":"Torch"}]}` || strings.Contains(string(input.Content), "item:sha256:") {
|
||||
t.Fatalf("registry prompt input = %#v, want names-only projection", input)
|
||||
@@ -98,7 +101,7 @@ func TestExtractAcceptsOnlyEmptyResponseForEmptyRegistry(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractPreservesNullableFields(t *testing.T) {
|
||||
client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)}
|
||||
client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)}
|
||||
req := extractionRequest()
|
||||
req.References = itemRegistryReferences(t)
|
||||
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
|
||||
|
||||
@@ -14,6 +14,6 @@ type itemOccurrenceResponse struct {
|
||||
}
|
||||
|
||||
type itemOccurrenceSourceRefResponse struct {
|
||||
StartSegment int `json:"start_segment"`
|
||||
EndSegment int `json:"end_segment"`
|
||||
StartUnitID int `json:"start_unit_id"`
|
||||
EndUnitID int `json:"end_unit_id"`
|
||||
}
|
||||
|
||||
@@ -43,6 +43,19 @@ func TestPromptAssetsPrepareItemOccurrencePrompt(t *testing.T) {
|
||||
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_item_occurrences_llm.v1.json" {
|
||||
t.Fatalf("prepared prompt = %#v", prepared)
|
||||
}
|
||||
rendered := make([]string, len(prepared.Messages))
|
||||
for index, message := range prepared.Messages {
|
||||
rendered[index] = message.Content
|
||||
}
|
||||
content := strings.Join(rendered, "\n")
|
||||
for _, field := range []string{"start_unit_id", "end_unit_id", "source_id"} {
|
||||
if !strings.Contains(content, field) {
|
||||
t.Fatalf("prepared prompt does not include shared evidence field %q", field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "start_segment") || strings.Contains(content, "end_segment") {
|
||||
t.Fatalf("prepared prompt contains obsolete segment evidence fields: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptAssetsDoNotLeakIntoMetadata(t *testing.T) {
|
||||
|
||||
@@ -20,11 +20,11 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
|
||||
valid := map[string]any{"occurrences": []any{
|
||||
map[string]any{
|
||||
"name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party",
|
||||
"source_refs": []any{map[string]any{"start_segment": 0, "end_segment": -1}},
|
||||
"source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": -1}},
|
||||
},
|
||||
map[string]any{
|
||||
"name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil,
|
||||
"source_refs": []any{map[string]any{"start_segment": 1, "end_segment": 1}},
|
||||
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}},
|
||||
},
|
||||
}}
|
||||
content, err := json.Marshal(valid)
|
||||
@@ -43,8 +43,10 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
|
||||
{"missing nullable field", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "quantity")}}},
|
||||
{"opaque item identifier", map[string]any{"occurrences": []any{withField(responseOccurrence(), "item_id", "item:sha256:opaque")}}},
|
||||
{"unknown occurrence field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "extra", true)}}},
|
||||
{"unknown reference field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1, "extra": true}})}}},
|
||||
{"noninteger range", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1.5, "end_segment": 1}})}}},
|
||||
{"segment-named range", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1}})}}},
|
||||
{"opaque source identifier", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1, "source_id": "session-alpha"}})}}},
|
||||
{"unknown reference field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1, "extra": true}})}}},
|
||||
{"noninteger range", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_unit_id": 1.5, "end_unit_id": 1}})}}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
content, err := json.Marshal(test.value)
|
||||
|
||||
@@ -44,7 +44,7 @@ func sourceDocument() *source.SourceDocument {
|
||||
}
|
||||
|
||||
func responseRefs(start, end int) []itemOccurrenceSourceRefResponse {
|
||||
return []itemOccurrenceSourceRefResponse{{StartSegment: start, EndSegment: end}}
|
||||
return []itemOccurrenceSourceRefResponse{{StartUnitID: start, EndUnitID: end}}
|
||||
}
|
||||
|
||||
func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor {
|
||||
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
|
||||
func newCatalogPromptInput(effective spellcatalog.EffectiveCatalog) (contracts.LLMInputMaterial, error) {
|
||||
content, err := json.Marshal(struct {
|
||||
SpellNames []string `json:"spell_names"`
|
||||
}{SpellNames: effective.CanonicalNames()})
|
||||
Spells []spellcatalog.PromptSpell `json:"spells"`
|
||||
}{Spells: effective.PromptSpells()})
|
||||
if err != nil {
|
||||
return contracts.LLMInputMaterial{}, fmt.Errorf("encode canonical spell names: %w", err)
|
||||
return contracts.LLMInputMaterial{}, fmt.Errorf("encode spell recognition catalog: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
digest := "sha256:" + hex.EncodeToString(sum[:])
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) {
|
||||
t.Fatalf("catalog prompt input metadata = %#v", catalogInput)
|
||||
}
|
||||
var catalogPayload struct {
|
||||
SpellNames []string `json:"spell_names"`
|
||||
Spells []spellcatalog.PromptSpell `json:"spells"`
|
||||
}
|
||||
if err := json.Unmarshal(catalogInput.Content, &catalogPayload); err != nil {
|
||||
t.Fatalf("decode catalog prompt input: %v", err)
|
||||
@@ -80,24 +80,42 @@ func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) {
|
||||
wantNames = append(wantNames, spell.Name)
|
||||
}
|
||||
sort.Strings(wantNames)
|
||||
if !reflect.DeepEqual(catalogPayload.SpellNames, wantNames) || !sort.StringsAreSorted(catalogPayload.SpellNames) {
|
||||
t.Fatalf("catalog prompt names = %d entries, want sorted base catalog", len(catalogPayload.SpellNames))
|
||||
gotNames := make([]string, len(catalogPayload.Spells))
|
||||
for index, spell := range catalogPayload.Spells {
|
||||
gotNames[index] = spell.CanonicalName
|
||||
if !sort.StringsAreSorted(spell.Aliases) {
|
||||
t.Fatalf("catalog prompt aliases for %q are not sorted: %#v", spell.CanonicalName, spell.Aliases)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(gotNames, wantNames) || !sort.StringsAreSorted(gotNames) {
|
||||
t.Fatalf("catalog prompt names = %d entries, want sorted base catalog", len(gotNames))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPromptUsesCanonicalOverlayNamesWithoutAliasesOrMetadata(t *testing.T) {
|
||||
func TestExtractPromptProjectsCanonicalNamesAndAliasesWithoutMetadata(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
|
||||
if _, err := newExtractor(t, client, overlaySpellCatalogReference()).Extract(context.Background(), extractionRequest()); err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
input := client.requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot]
|
||||
content := string(input.Content)
|
||||
for _, expected := range []string{"Aegis of Emberfall", `"spell_names"`} {
|
||||
if !strings.Contains(content, expected) {
|
||||
t.Fatalf("catalog prompt input = %q, want %q", content, expected)
|
||||
var payload struct {
|
||||
Spells []spellcatalog.PromptSpell `json:"spells"`
|
||||
}
|
||||
if err := json.Unmarshal(input.Content, &payload); err != nil {
|
||||
t.Fatalf("decode catalog prompt input: %v", err)
|
||||
}
|
||||
var aegis *spellcatalog.PromptSpell
|
||||
for index := range payload.Spells {
|
||||
if payload.Spells[index].CanonicalName == "Aegis of Emberfall" {
|
||||
aegis = &payload.Spells[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"Emberfall Aegis", "Private campaign source", "file:///private-source.json", "private"} {
|
||||
if aegis == nil || !reflect.DeepEqual(aegis.Aliases, []string{"Emberfall Aegis"}) {
|
||||
t.Fatalf("Aegis prompt projection = %#v, want canonical name and alias", aegis)
|
||||
}
|
||||
for _, forbidden := range []string{"Private campaign source", "file:///private-source.json", "private", "license", "ruleset", "provenance"} {
|
||||
if strings.Contains(content, forbidden) {
|
||||
t.Fatalf("catalog prompt input leaked %q: %s", forbidden, content)
|
||||
}
|
||||
@@ -143,6 +161,60 @@ func TestExtractPromptUsesCanonicalOverlayNamesWithoutAliasesOrMetadata(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractUsesAliasRecognitionToRequestCanonicalSpellNames(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{
|
||||
Caster: "Aria",
|
||||
Spell: "Aegis of Emberfall",
|
||||
SourceRefs: responseSourceRefs(1, 1),
|
||||
}}}}
|
||||
request := extractionRequest()
|
||||
request.Chunk.Content = []byte(`{"segments":[{"id":1,"text":"Aria invokes Emberfall Aegis."}]}`)
|
||||
request.SourceInput = spellChunkInput(request.Chunk)
|
||||
|
||||
result, err := newExtractor(t, client, overlaySpellCatalogReference()).Extract(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
if got := result.Value.SpellCasts; len(got) != 1 || got[0].Spell != "Aegis of Emberfall" {
|
||||
t.Fatalf("spell casts = %#v, want canonical spell name", got)
|
||||
}
|
||||
input := client.requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot]
|
||||
if !strings.Contains(string(input.Content), `"aliases":["Emberfall Aegis"]`) {
|
||||
t.Fatalf("catalog prompt input = %s, want transcript alias recognition", input.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAliasOnlyCatalogChangesPromptMaterialAndCheckpointFingerprint(t *testing.T) {
|
||||
aliasReference := spellCatalogReference(`{"schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[{"id":"campaign.example","ruleset":"dnd-5e-2014","source":{"title":"Private campaign source","version":"1","url":"file:///private-source.json","license":"private"},"spells":[{"name":"Cure Wounds","aliases":["Campaign Woundweave"]}]}]}`)
|
||||
baseClient := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
|
||||
aliasClient := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
|
||||
baseExtractor := newExtractor(t, baseClient)
|
||||
aliasExtractor := newExtractor(t, aliasClient, aliasReference)
|
||||
if _, err := baseExtractor.Extract(context.Background(), extractionRequest()); err != nil {
|
||||
t.Fatalf("base Extract() error = %v", err)
|
||||
}
|
||||
if _, err := aliasExtractor.Extract(context.Background(), extractionRequest()); err != nil {
|
||||
t.Fatalf("alias Extract() error = %v", err)
|
||||
}
|
||||
|
||||
baseInput := baseClient.requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot]
|
||||
aliasInput := aliasClient.requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot]
|
||||
if baseInput.Digest == aliasInput.Digest || string(baseInput.Content) == string(aliasInput.Content) {
|
||||
t.Fatalf("alias-only catalog did not change prompt material: %q / %q", baseInput.Digest, aliasInput.Digest)
|
||||
}
|
||||
if !strings.Contains(string(aliasInput.Content), "Campaign Woundweave") {
|
||||
t.Fatalf("alias prompt input = %s, want alias recognition", aliasInput.Content)
|
||||
}
|
||||
baseFingerprints := checkpointFingerprintMap(baseExtractor.CheckpointFingerprints())
|
||||
aliasFingerprints := checkpointFingerprintMap(aliasExtractor.CheckpointFingerprints())
|
||||
if baseFingerprints["effective_catalog"] == aliasFingerprints["effective_catalog"] {
|
||||
t.Fatalf("effective catalog fingerprint did not change: %#v", aliasFingerprints)
|
||||
}
|
||||
if baseExtractor.ManifestMetadata()["prompt_id"] != aliasExtractor.ManifestMetadata()["prompt_id"] || baseExtractor.ManifestMetadata()["prompt_version"] != aliasExtractor.ManifestMetadata()["prompt_version"] {
|
||||
t.Fatal("catalog-only change altered prompt identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsMalformedCatalogBeforeLLMCall(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{}
|
||||
_, err := New(client, Options{}, spellCatalogReference(`{"schema_version":"notarius.dnd.spell-catalog-overlay.v2","catalogs":[]}`))
|
||||
|
||||
@@ -107,7 +107,7 @@ func prepareSpellsPrompt(t *testing.T, transcript []byte, players string, party
|
||||
ProfileID: "spell-test-profile",
|
||||
Inputs: map[string]promptkit.ArtifactRef{
|
||||
"transcript": promptkit.InlineWithURI("file:///session.json", string(transcript)),
|
||||
"spell_catalog": promptkit.Inline(`{"spell_names":["spell-catalog-sentinel"]}`),
|
||||
"spell_catalog": promptkit.Inline(`{"spells":[{"canonical_name":"spell-catalog-sentinel","aliases":["spell-alias-sentinel"]}]}`),
|
||||
"npc_registry": promptkit.Inline(`{"npcs":[{"name":"spell-npc-sentinel"}]}`),
|
||||
"players": promptkit.Inline(players),
|
||||
"party": promptkit.Inline(party),
|
||||
|
||||
@@ -93,6 +93,14 @@ func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
|
||||
// comparable through SourceRefOrder's literal fallback so malformed candidates
|
||||
// are still safe to sort and diagnose.
|
||||
func Less(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool {
|
||||
left.SourceRefs = order.Canonicalize(left.SourceRefs)
|
||||
right.SourceRefs = order.Canonicalize(right.SourceRefs)
|
||||
return CanonicalLess(order, left, right)
|
||||
}
|
||||
|
||||
// CanonicalLess defines the canonical occurrence order without modifying or
|
||||
// canonicalizing source references. Callers must provide canonical evidence.
|
||||
func CanonicalLess(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool {
|
||||
leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs)
|
||||
rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs)
|
||||
if leftHasEvidence != rightHasEvidence {
|
||||
@@ -122,12 +130,20 @@ func Less(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool {
|
||||
if less, decided := optionalQuantityLess(left.Quantity, right.Quantity); decided {
|
||||
return less
|
||||
}
|
||||
return sourceRefsLess(order, order.Canonicalize(left.SourceRefs), order.Canonicalize(right.SourceRefs))
|
||||
return sourceRefsLess(order, left.SourceRefs, right.SourceRefs)
|
||||
}
|
||||
|
||||
// ExactEqual reports whether occurrences are exact duplicates after their display
|
||||
// fields and evidence have been canonicalized for the supplied source order.
|
||||
func ExactEqual(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool {
|
||||
left.SourceRefs = order.Canonicalize(left.SourceRefs)
|
||||
right.SourceRefs = order.Canonicalize(right.SourceRefs)
|
||||
return CanonicalExactEqual(left, right)
|
||||
}
|
||||
|
||||
// CanonicalExactEqual reports whether canonical occurrences are exact
|
||||
// duplicates without modifying or canonicalizing their source references.
|
||||
func CanonicalExactEqual(left, right dnd.ItemOccurrence) bool {
|
||||
if left.ItemID != right.ItemID || DisplayValue(left.Name) != DisplayValue(right.Name) || left.Kind != right.Kind ||
|
||||
DisplayValue(left.From) != DisplayValue(right.From) || DisplayValue(left.To) != DisplayValue(right.To) ||
|
||||
(left.Quantity == nil) != (right.Quantity == nil) {
|
||||
@@ -136,13 +152,21 @@ func ExactEqual(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) boo
|
||||
if left.Quantity != nil && *left.Quantity != *right.Quantity {
|
||||
return false
|
||||
}
|
||||
return SourceRefsEqual(order.Canonicalize(left.SourceRefs), order.Canonicalize(right.SourceRefs))
|
||||
return SourceRefsEqual(left.SourceRefs, right.SourceRefs)
|
||||
}
|
||||
|
||||
// ExactIdentity returns a collision-safe duplicate key after display and
|
||||
// evidence canonicalization. It is intended for callers that have already
|
||||
// decided the occurrence is eligible for duplicate handling.
|
||||
func ExactIdentity(order shared.SourceRefOrder, occurrence dnd.ItemOccurrence) string {
|
||||
occurrence.SourceRefs = order.Canonicalize(occurrence.SourceRefs)
|
||||
return CanonicalExactIdentity(occurrence)
|
||||
}
|
||||
|
||||
// CanonicalExactIdentity returns a collision-safe duplicate key without
|
||||
// modifying or canonicalizing source references. Callers must provide
|
||||
// canonical evidence.
|
||||
func CanonicalExactIdentity(occurrence dnd.ItemOccurrence) string {
|
||||
var key strings.Builder
|
||||
writeKeyString(&key, occurrence.ItemID)
|
||||
writeKeyString(&key, DisplayValue(occurrence.Name))
|
||||
@@ -155,14 +179,24 @@ func ExactIdentity(order shared.SourceRefOrder, occurrence dnd.ItemOccurrence) s
|
||||
key.WriteByte('1')
|
||||
writeKeyInt(&key, *occurrence.Quantity)
|
||||
}
|
||||
for _, ref := range order.Canonicalize(occurrence.SourceRefs) {
|
||||
writeKeyString(&key, ref.SourceID)
|
||||
writeKeyInt(&key, ref.StartUnitID)
|
||||
writeKeyInt(&key, ref.EndUnitID)
|
||||
}
|
||||
writeKeySourceRefs(&key, occurrence.SourceRefs)
|
||||
return key.String()
|
||||
}
|
||||
|
||||
func writeKeySourceRefs(builder *strings.Builder, refs []source.SourceRef) {
|
||||
if refs == nil {
|
||||
builder.WriteByte('0')
|
||||
return
|
||||
}
|
||||
builder.WriteByte('1')
|
||||
writeKeyInt(builder, len(refs))
|
||||
for _, ref := range refs {
|
||||
writeKeyString(builder, ref.SourceID)
|
||||
writeKeyInt(builder, ref.StartUnitID)
|
||||
writeKeyInt(builder, ref.EndUnitID)
|
||||
}
|
||||
}
|
||||
|
||||
func optionalStringLess(left, right string) (bool, bool) {
|
||||
leftPresent, rightPresent := HolderPresent(left), HolderPresent(right)
|
||||
if leftPresent != rightPresent {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user