Compare commits

...

41 Commits

Author SHA1 Message Date
ef19a03cbf Reconcile remediation documentation 2026-08-09 02:45:22 +00:00
0546f6eb4f Simplify module cleanup paths 2026-08-09 02:40:25 +00:00
d28d1062e0 Reuse canonical item occurrence evidence 2026-08-09 02:36:53 +00:00
b3ebfcef37 Index enemy event duplicate identities 2026-08-09 02:31:28 +00:00
b70d9f77e3 Improve D&D registry normalization efficiency 2026-08-09 02:26:49 +00:00
2a75f40871 Bound D&D normalization diagnostics 2026-08-09 02:22:23 +00:00
a705ba74a1 Project spell aliases into extraction prompts 2026-08-09 02:17:15 +00:00
8d9c9e7c87 Align item occurrence evidence fields 2026-08-09 02:11:27 +00:00
0b5cc4f251 Require chunk-local extraction evidence 2026-08-09 02:08:11 +00:00
82ffe85f2d Enforce durable enemy event validation 2026-08-09 02:03:27 +00:00
b3644abc0e Enforce durable D&D evidence ranges 2026-08-09 01:54:43 +00:00
d653bf1b90 Share immutable in-memory filesystems 2026-08-09 01:47:44 +00:00
3e66127b94 Write durable outputs through confined file writer 2026-08-09 01:41:37 +00:00
5d086c13ca Cache compiled JSON schemas per validator 2026-08-09 01:38:42 +00:00
d36d4e7689 Avoid redundant decoded graph clones 2026-08-09 01:34:26 +00:00
1456aa51cc Index generated reference handoffs 2026-08-09 01:30:10 +00:00
ffc179c822 Centralize builder request cloning 2026-08-09 01:27:28 +00:00
8e669a1f14 Preserve terminal rejection warnings 2026-08-09 01:19:55 +00:00
14bfae216d Isolate typed validator candidates 2026-08-09 01:11:03 +00:00
5a58d87995 Require candidate decoders for artifact codecs 2026-08-09 01:03:50 +00:00
557809f364 Add candidate artifact codec decoding 2026-08-09 00:57:03 +00:00
ee600975f0 Prevent scheduler callbacks after cancellation 2026-08-09 00:52:31 +00:00
0d8017e23f Gate runner dispatches on cancellation 2026-08-09 00:50:02 +00:00
37b18edf3d Contain provider errors at the LLM adapter 2026-08-09 00:44:38 +00:00
cda7a61b47 Encode checkpoint and debug path identities 2026-08-09 00:41:28 +00:00
2ad9283148 Bound external reference reads and fingerprints 2026-08-09 00:33:33 +00:00
41a8a80dda Reject ambiguous configuration and reference bindings 2026-08-09 00:29:04 +00:00
90c7fa6381 Finalize codebase audit synthesis 2026-08-08 23:00:28 +00:00
e3839f8620 Audit combat and enemy event processing 2026-08-08 22:54:00 +00:00
0fc2f9ee01 Audit spell and scene processing 2026-08-08 22:42:49 +00:00
5d6305f21a Audit NPC item and location occurrences 2026-08-08 22:30:23 +00:00
551e4daea2 Audit NPC item and location registries 2026-08-08 22:18:29 +00:00
3589d33468 Audit shared D&D family conventions 2026-08-08 22:08:24 +00:00
a22c1a7f59 Audit generic and Seriatim modules 2026-08-08 21:59:06 +00:00
ad85d71b0f Audit LLM runtime and prompt assets 2026-08-08 21:47:51 +00:00
f3506240c2 Audit state persistence and file safety 2026-08-08 21:37:57 +00:00
70c199aa31 Audit runtime execution and concurrency 2026-08-08 21:27:37 +00:00
e2b82746ab Audit reference materialization and ordered handoffs 2026-08-08 21:16:30 +00:00
4235507f7b Audit pipeline composition and typed registries 2026-08-08 21:06:28 +00:00
b346670cc7 Audit configuration and CLI composition 2026-08-08 20:56:50 +00:00
7868c26be7 Establish codebase audit baseline 2026-08-08 20:50:15 +00:00
142 changed files with 6568 additions and 1031 deletions

View File

@@ -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"}
}
}
}

View File

@@ -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" }}

View File

@@ -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

View File

@@ -67,6 +67,12 @@ including a collision with the embedded catalog. Matching uses the catalogs
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).

View File

@@ -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).

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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
}

View File

@@ -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)
}
}

View File

@@ -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}]}]}`)

View File

@@ -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()

View File

@@ -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

View File

@@ -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())
}

View File

@@ -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" {

View File

@@ -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" {

View File

@@ -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
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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
}

View File

@@ -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:

View File

@@ -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 == "" {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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 }

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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:

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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}

View File

@@ -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 {

View 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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 }

View File

@@ -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") {

View 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 }

View 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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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] = '['

View File

@@ -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)
}
}
}

View File

@@ -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()

View File

@@ -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)
}
}
}

View File

@@ -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 }{

View File

@@ -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)
}
}
}

View File

@@ -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 }{

View File

@@ -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)
}
}
}

View File

@@ -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 }{

View File

@@ -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)
}
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View 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
}

View 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)
}
})
}
}

View File

@@ -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)
}
}
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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...),

View File

@@ -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{{

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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"`
}

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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[:])

View File

@@ -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":[]}`))

View File

@@ -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),

View File

@@ -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