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": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["start_segment", "end_segment"], "required": ["start_unit_id", "end_unit_id"],
"properties": { "properties": {
"start_segment": {"type": "integer"}, "start_unit_id": {"type": "integer"},
"end_segment": {"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. The spell catalog for this extraction is provided below as JSON. Each entry
Return spell names using the catalog's canonical spelling exactly. Aliases and lists a `canonical_name` and its recognized `aliases`. If the transcript uses
other campaign reference material are not part of this catalog input and must an alias, select that entry's `canonical_name`. Return spell names using the
not be copied into the output as spell names. canonical spelling exactly; never return an alias as a spell name.
{{ input "spell_catalog" }} {{ 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. multiple supplied records share that name.
The LLM boundary must preserve the typed artifact and durable-schema ownership 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 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 ## 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 case, whitespace, and apostrophe normalization, so authors should avoid names
or aliases that normalize to another spell. 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 The overlay is a recognition aid only. The durable spell-artifact schema and
source-evidence rules are defined by the source-evidence rules are defined by the
[D&D spell artifact contract](dnd-spell-artifacts.md). [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 the current-chunk `common-dnd-transcript-chunk.md`; and NPC, location, and item
normalization consume `common-dnd-transcript-windows.md` alongside their normalization consume `common-dnd-transcript-windows.md` alongside their
candidate collections. Player, party, glossary, and compatible campaign candidate collections. Player, party, glossary, and compatible campaign
references provide disambiguating context, not evidence. Reference material is references provide disambiguating context only when declared by the active
canonically ordered before rendering so equivalent inputs remain stable. 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 Extraction prompts render the common system and identity messages first, then
cached campaign references and the cached chunk transcript. Evidence policy and 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 Default chains keep responsibilities separate: structural validators assess the
candidate, source-reference validators resolve cited ranges against the current 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 relatedness validators report advisory evidence concerns. The configured order
is documented in is documented in
[Configuration](../config.md#production-validator-keys-and-default-chains). [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 Production construction creates one PromptKit client and wraps it in one
scheduled client. The scheduler has a fixed, positive permit limit, serves 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 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 The scheduled wrapper surrounds every `CompleteStructured` call, so concurrent
lanes, pipeline retries, and LLM-backed validators share the same provider-call 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 failure, empty structured body, or decode failure as
`ErrInvalidStructuredOutput`, while retaining the returned raw bytes and debug `ErrInvalidStructuredOutput`, while retaining the returned raw bytes and debug
material when they exist. Provider failures remain operational errors rather 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 When PromptKit rejects backend admission before generation, the adapter maps
`promptkit.ErrCapacityExceeded` to `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 checks that each slot is declared by the selected module, resolves a file path
relative to the correct configuration or working-directory origin, reads relative to the correct configuration or working-directory origin, reads
UTF-8 text, verifies media type and size limits, and retains bounded UTF-8 text, verifies media type and size limits, and retains bounded
provenance. A generated-artifact selector remains declared but has no bytes provenance. For a positive slot limit, it reads at most the limit plus one byte
until its producing step completes. 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 Preparation is the construction boundary. It validates the resolved shape and
registry set, clones the resolved data, then constructs the input adapter, registry set, clones the resolved data, then constructs the input adapter,
chunker, stage-local validators, every typed lane, and output encoder with chunker, stage-local validators, every typed lane, and output encoder. Each
cloned options, references, and shared dependencies. It also collects stable registered builder receives its own cloned build request immediately before its
checkpoint fingerprints. Missing registrations, incompatible typed entries, module-owned code runs. Preparation also collects stable checkpoint
nil implementations, and constructor failures are reported before source fingerprints. Missing registrations, incompatible typed entries, nil
parsing or any stage operation begins. 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 An output encoder can opt into source-evidence publication through its output
policy. Preparation keeps the configured lane allowlist and active lanes 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 Every chunk, extract, merge, and normalize candidate passes its resolved
validator chain. Validators receive immutable canonical input appropriate to validator chain. Validators receive immutable canonical input appropriate to
their target: chunks, typed values, or serialized codec bytes. They may their target: chunks, codec-decoded typed candidates, or serialized codec
approve, approve with warnings, reject, or fail. A rejection is an ordinary bytes. Each typed validator receives a newly decoded value from the one
pipeline result; a validator error is a framework error. 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 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 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. they do not describe a current public state surface.
Ordered-step lane checkpoints include the step identity in their storage scope. 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 When a later lane consumes a generated artifact, its dependency fingerprints
include the producer's artifact kind, complete schema identity, media type, include the producer's artifact kind, complete schema identity, media type,
canonical content digest, and size. Ordinary resume compares those fingerprints 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}) registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{unknownSpell: true})
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{}) prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
if err != nil { if err != nil {
@@ -161,10 +161,8 @@ func TestAssembledSpellPipelineRejectsUnknownSpellWithoutPromotingAttemptWarning
if !reflect.DeepEqual(rejectedFile.Rejected, output.Rejected) { if !reflect.DeepEqual(rejectedFile.Rejected, output.Rejected) {
t.Fatalf("rejected file = %#v, run rejections = %#v, want durable rejection diagnostic", 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 len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != spellnormalize.ReasonCodeSpellNameUnresolved || output.Warnings[0].Scope != "spell_casts[0]" {
if warning.ReasonCode == spellnormalize.ReasonCodeSpellNameUnresolved { t.Fatalf("warnings = %#v, want terminal normalize catalog warning", output.Warnings)
t.Fatalf("warnings = %#v, want rejected-attempt warning to remain non-durable", 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" { 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)) 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: case combat.PromptID:
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`) 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) { func (referenceContractCodecA) Decode([]byte) (stateTestArtifact, error) {
return stateTestArtifact{Value: "ok"}, nil 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) Kind() contracts.ArtifactKind { return referenceContractKindBeta }
func (referenceContractCodecB) Schema() contracts.ArtifactSchema { func (referenceContractCodecB) Schema() contracts.ArtifactSchema {
@@ -415,6 +418,9 @@ func (referenceContractCodecB) Encode(stateTestArtifact) ([]byte, error) {
func (referenceContractCodecB) Decode([]byte) (stateTestArtifact, error) { func (referenceContractCodecB) Decode([]byte) (stateTestArtifact, error) {
return stateTestArtifact{Value: "ok"}, nil 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 { func referenceContractLane(t *testing.T, resolved pipeline.ResolvedPipeline, id string) pipeline.ResolvedArtifactLane {
t.Helper() t.Helper()

View File

@@ -19,6 +19,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config" "gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle" "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/checkpoint"
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkplan" "gitea.maximumdirect.net/eric/notarius/internal/framework/chunkplan"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "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") machineOutput := fs.Bool("json", false, "write the successful run result as JSON")
debug := fs.Bool("debug", false, "write a debug bundle") debug := fs.Bool("debug", false, "write a debug bundle")
debugDir := fs.String("debug-dir", "", "debug bundle directory") 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"} reasoningEffort := singleValueFlag{name: "--reasoning-effort"}
clearReasoningEffort := fs.Bool("clear-reasoning-effort", false, "clear the LLM profile reasoning effort") clearReasoningEffort := fs.Bool("clear-reasoning-effort", false, "clear the LLM profile reasoning effort")
resume := fs.Bool("resume", false, "reuse compatible recorded checkpoints") 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{} referenceFlags := stringListFlag{}
withoutReferenceFlags := stringListFlag{} withoutReferenceFlags := stringListFlag{}
fs.Var(&requestedSessionID, "session-id", "prompt session identifier") 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(&reasoningEffort, "reasoning-effort", "reasoning effort override")
fs.Var(&chunkCache, "chunk_cache", "chunk plan cache mode: auto, bypass, or refresh") 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") 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") fmt.Fprintln(stderr, "notarius: --session-id must not be empty")
return 2 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 { if reasoningEffort.set && *clearReasoningEffort {
fmt.Fprintln(stderr, "notarius: --reasoning-effort cannot be combined with --clear-reasoning-effort") fmt.Fprintln(stderr, "notarius: --reasoning-effort cannot be combined with --clear-reasoning-effort")
return 2 return 2
@@ -338,7 +344,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
PipelineID: pipelineID, PipelineID: pipelineID,
Only: only, Only: only,
Catalog: catalog, Catalog: catalog,
LLMProfileOverride: *llmProfile, LLMProfileOverride: strings.TrimSpace(llmProfile.value),
ReferenceOverrides: referenceOverrides, ReferenceOverrides: referenceOverrides,
ReferenceUnbinds: referenceUnbinds, ReferenceUnbinds: referenceUnbinds,
}) })
@@ -426,7 +432,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
if err != nil { if err != nil {
return failPipelineCommand(stderr, commandState, terminalWriter, err) 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 { if err != nil {
return failPipelineCommand(stderr, commandState, terminalWriter, err) return failPipelineCommand(stderr, commandState, terminalWriter, err)
} }
@@ -752,17 +758,10 @@ func configSource(configPath string) string {
} }
func writeOutputFiles(runOutputDir string, files []contracts.OutputFile) error { 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 { for _, file := range files {
targetPath, err := outputFilePath(runOutputDir, file.Name) if _, err := outputFilePath(runOutputDir, file.Name); err != nil {
if err != nil {
return err return err
} }
targets = append(targets, outputTarget{path: targetPath, file: file})
} }
outputParent := filepath.Dir(runOutputDir) 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) return fmt.Errorf("create output run directory %q: %w", runOutputDir, err)
} }
for _, target := range targets { for _, file := range files {
if err := os.MkdirAll(filepath.Dir(target.path), 0o755); err != nil { if err := fileio.WriteBytes(runOutputDir, file.Name, file.Bytes, 0o755, 0o644); err != nil {
return fmt.Errorf("create output directory %q: %w", filepath.Dir(target.path), err) return fmt.Errorf("write output file %q: %w", file.Name, err)
}
if err := writeFileAtomic(target.path, target.file.Bytes, 0o644); err != nil {
return fmt.Errorf("write output file %q: %w", target.file.Name, err)
} }
} }
return nil return nil
@@ -823,38 +819,6 @@ func outputFilePath(runOutputDir, logicalName string) (string, error) {
return target, nil 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 { func reorderRunArgs(args []string) []string {
var flags []string var flags []string
var positionals []string var positionals []string

View File

@@ -43,6 +43,12 @@ func TestRunControlsRejectSyntaxWithoutAllocatingState(t *testing.T) {
{name: "blank session ID", args: func(roots stateTestRoots) []string { {name: "blank session ID", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--session-id", ""} 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 { {name: "multiple pipeline IDs", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "extra", "--config", roots.config, "--input", roots.input} return []string{"run", "sample", "extra", "--config", roots.config, "--input", roots.input}
}}, }},
@@ -253,7 +259,7 @@ func TestRunLLMProfileOverrideAndValidationUseInjectedBoundaries(t *testing.T) {
return nil, nil, nil return nil, nil, nil
} }
var stdout, stderr bytes.Buffer 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 { if code != 0 || stderr.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) 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 { if err := writeOutputFiles(runPath, []contracts.OutputFile{{Name: "nested/result.json", Bytes: []byte("result")}}); err != nil {
t.Fatal(err) 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" { if err != nil || string(data) != "result" {
t.Fatalf("nested output = %q, %v", data, err) 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) { func TestWriteOutputFilesRejectsUnsafeNamesBeforeAllocatingRunDirectory(t *testing.T) {
@@ -75,7 +94,7 @@ func TestWriteOutputFilesRetainsNewPartialDirectoryAndPreservesSibling(t *testin
{Name: "blocked", Bytes: []byte("partial output")}, {Name: "blocked", Bytes: []byte("partial output")},
{Name: "blocked/nested.json", Bytes: []byte("unreachable")}, {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) 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" { 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)) t.Fatalf("spell requests = %d, want one", len(requests))
} }
catalogInput, ok := requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot] catalogInput, ok := requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot]
if !ok || !strings.Contains(string(catalogInput.Content), "Aegis of Emberfall") || strings.Contains(string(catalogInput.Content), "Emberfall Aegis") { 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 without alias", catalogInput) 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")) artifact := readProductionJSON[dnd.SpellList](t, filepath.Join(runRoot, "lanes", "spells.json"))
if len(artifact.SpellCasts) != 1 || artifact.SpellCasts[0].Spell != "Aegis of Emberfall" { 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) t.Fatalf("rejection = %#v, want exhausted unknown-spell rejection", rejection)
} }
if len(output.Warnings) != 0 { 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 return
} }

View File

@@ -955,6 +955,9 @@ func (stateTestCodec) Encode(v stateTestArtifact) ([]byte, error) {
func (stateTestCodec) Decode([]byte) (stateTestArtifact, error) { func (stateTestCodec) Decode([]byte) (stateTestArtifact, error) {
return stateTestArtifact{Value: "ok"}, nil return stateTestArtifact{Value: "ok"}, nil
} }
func (codec stateTestCodec) DecodeCandidate(content []byte) (stateTestArtifact, error) {
return codec.Decode(content)
}
type stateTestExtractor struct{ harness *stateTestHarness } type stateTestExtractor struct{ harness *stateTestHarness }

View File

@@ -427,6 +427,10 @@ func (effectiveCodec) Decode(content []byte) (effectiveArtifact, error) {
return value, err return value, err
} }
func (codec effectiveCodec) DecodeCandidate(content []byte) (effectiveArtifact, error) {
return codec.Decode(content)
}
type effectiveInput struct{ key string } type effectiveInput struct{ key string }
func (m effectiveInput) Key() string { return m.key } func (m effectiveInput) Key() string { return m.key }

View File

@@ -3,6 +3,7 @@ package config
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -349,6 +350,12 @@ func ParseFileConfigYAML(data []byte) (FileConfig, error) {
if err := decoder.Decode(&fileCfg); err != nil { if err := decoder.Decode(&fileCfg); err != nil {
return FileConfig{}, fmt.Errorf("decode yaml: %w", err) 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 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) { func TestFilePipelineLLMProfileIsPresenceAwareAndDetached(t *testing.T) {
const pipelineYAML = `version: 4 const pipelineYAML = `version: 4
pipelines: pipelines:

View File

@@ -10,6 +10,38 @@ import (
"strings" "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) { func SafePath(root, name string) (string, error) {
root = strings.TrimSpace(root) root = strings.TrimSpace(root)
if 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) { func TestWriteBytesIsAtomicAndUsesRequestedModes(t *testing.T) {
root := t.TempDir() root := t.TempDir()
if err := WriteBytes(root, "nested/value", []byte("value"), 0o700, 0o600); err != nil { 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 { func lanePayloadPath(stage string, stepID string, laneID string, file string) string {
if strings.TrimSpace(stepID) == "" { 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) return path.Join(stage, fileio.EncodePathComponent(stepID), fileio.EncodePathComponent(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
} }
func contentDigest(content []byte) string { func contentDigest(content []byte) string {

View File

@@ -5,6 +5,8 @@ import (
"path/filepath" "path/filepath"
"testing" "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/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) { func TestCheckpointSchemaCompatibilityIdentifiers(t *testing.T) {
if WorkspaceSchemaVersion != "notarius.workspace.v3" || WorkspaceSchemaVersionV2 != "notarius.workspace.v2" || WorkspaceSchemaVersionV1 != "notarius.workspace.v1" { if WorkspaceSchemaVersion != "notarius.workspace.v3" || WorkspaceSchemaVersionV2 != "notarius.workspace.v2" || WorkspaceSchemaVersionV1 != "notarius.workspace.v1" {
t.Fatal("checkpoint schema identifiers are incorrect") t.Fatal("checkpoint schema identifiers are incorrect")

View File

@@ -102,11 +102,11 @@ func Build(request BuildRequest) (ChunkMap, error) {
Annotations: source.CloneChunkAnnotations(chunk.Annotations), Annotations: source.CloneChunkAnnotations(chunk.Annotations),
} }
} }
canonical, err := canonicalize(value) canonical, err := canonicalizeOwned(value)
if err != nil { if err != nil {
return ChunkMap{}, fmt.Errorf("validate chunk map: %w", err) 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. // 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 { if _, err := c.schemaBytes(); err != nil {
return nil, err return nil, err
} }
canonical, err := canonicalize(clone(value)) canonical, err := canonicalize(value)
if err != nil { if err != nil {
return nil, fmt.Errorf("encode source chunk map: %w", err) 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 { if err := decoder.Decode(&trailing); err != io.EOF {
return ChunkMap{}, fmt.Errorf("decode source chunk map: multiple JSON values") return ChunkMap{}, fmt.Errorf("decode source chunk map: multiple JSON values")
} }
canonical, err := canonicalize(value) canonical, err := canonicalizeOwned(value)
if err != nil { if err != nil {
return ChunkMap{}, fmt.Errorf("decode source chunk map: %w", err) return ChunkMap{}, fmt.Errorf("decode source chunk map: %w", err)
} }
return clone(canonical), nil return canonical, nil
} }
func (c *Codec) schemaBytes() ([]byte, error) { func (c *Codec) schemaBytes() ([]byte, error) {
@@ -241,6 +241,10 @@ func hasRequiredFields(required []string) bool {
} }
func canonicalize(value ChunkMap) (ChunkMap, error) { 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 { if err := requireIdentity("source_id", value.SourceID); err != nil {
return ChunkMap{}, err return ChunkMap{}, err
} }

View File

@@ -56,6 +56,15 @@ func TestCodecRoundTripsValidFixture(t *testing.T) {
if !bytes.Equal(encoded, bytes.TrimSpace(fixture)) { if !bytes.Equal(encoded, bytes.TrimSpace(fixture)) {
t.Fatalf("fixture does not use canonical encoding\nwant: %s\n got: %s", fixture, encoded) 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) { func TestBuildCanonicalizesAnnotationFormatting(t *testing.T) {

View File

@@ -50,6 +50,13 @@ type ArtifactCodec[T any] interface {
Decode([]byte) (T, error) 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 // DigestArtifactSchema returns the SHA-256 digest of the exact JSON Schema
// bytes. Schema formatting is therefore part of the registered identity. // bytes. Schema formatting is therefore part of the registered identity.
func DigestArtifactSchema(schema ArtifactSchema) string { func DigestArtifactSchema(schema ArtifactSchema) string {

View File

@@ -84,11 +84,11 @@ func Build(request BuildRequest) (Document, error) {
} }
document.Contexts = append(document.Contexts, context) document.Contexts = append(document.Contexts, context)
} }
canonical, err := canonicalize(document) canonical, err := canonicalizeOwned(document)
if err != nil { if err != nil {
return Document{}, fmt.Errorf("validate evidence context: %w", err) return Document{}, fmt.Errorf("validate evidence context: %w", err)
} }
return clone(canonical) return canonical, nil
} }
type evidenceKey struct { 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 { if err := decoder.Decode(&trailing); err != io.EOF {
return Document{}, fmt.Errorf("decode evidence context: multiple JSON values") return Document{}, fmt.Errorf("decode evidence context: multiple JSON values")
} }
canonical, err := canonicalize(value) canonical, err := canonicalizeOwned(value)
if err != nil { if err != nil {
return Document{}, fmt.Errorf("decode evidence context: %w", err) return Document{}, fmt.Errorf("decode evidence context: %w", err)
} }
return clone(canonical) return canonical, nil
} }
func (c *Codec) schemaBytes() ([]byte, error) { func (c *Codec) schemaBytes() ([]byte, error) {
@@ -171,7 +171,10 @@ func canonicalize(value Document) (Document, error) {
if err != nil { if err != nil {
return Document{}, err return Document{}, err
} }
value = owned return canonicalizeOwned(owned)
}
func canonicalizeOwned(value Document) (Document, error) {
if err := requireIdentity("source_id", value.SourceID); err != nil { if err := requireIdentity("source_id", value.SourceID); err != nil {
return Document{}, err return Document{}, err
} }
@@ -230,10 +233,7 @@ func canonicalizeContext(sourceID string, selected map[string]struct{}, seenUnit
} }
positions := make(map[int]int, len(value.Units)) positions := make(map[int]int, len(value.Units))
for unitIndex := range value.Units { for unitIndex := range value.Units {
unit, err := cloneSourceUnit(value.Units[unitIndex]) unit := value.Units[unitIndex]
if err != nil {
return Context{}, fmt.Errorf("%s.units[%d]: %w", prefix, unitIndex, err)
}
if unit.ID <= 0 || strings.TrimSpace(unit.Kind) == "" || strings.TrimSpace(unit.Text) == "" { 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) 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 positions[unit.ID] = unitIndex
seenUnits[unit.ID] = struct{}{} 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 { 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) 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) t.Fatalf("fixture does not use canonical encoding\nwant: %s\n got: %s", fixture, encoded)
} }
value.Contexts[0].Units[0].Text = "changed" value.Contexts[0].Units[0].Text = "changed"
decoded, err := codec.Decode(fixture) decoded, err := codec.Decode(encoded)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if decoded.Contexts[0].Units[0].Text != "The party meets Rowan." { 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)}}}}) 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 { if err != nil {

View File

@@ -1,17 +1,15 @@
package llm package llm
import ( import (
"bytes"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"path" "path"
"sort"
"strings" "strings"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/readonlyfs"
"gitea.maximumdirect.net/eric/promptkit" "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) { func flattenAssetSources(sources []AssetSource) (fs.FS, error) {
out := assetMapFS{} out := make(map[string][]byte)
for _, source := range sources { for _, source := range sources {
if err := fs.WalkDir(source.FS, source.Root, func(name string, entry fs.DirEntry, walkErr error) error { if err := fs.WalkDir(source.FS, source.Root, func(name string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
@@ -247,13 +245,13 @@ func flattenAssetSources(sources []AssetSource) (fs.FS, error) {
if err != nil { if err != nil {
return err return err
} }
out[rel] = append([]byte(nil), data...) out[rel] = data
return nil return nil
}); err != nil { }); err != nil {
return nil, fmt.Errorf("walk asset root %s: %w", source.Root, err) 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) { func cleanAssetRoot(root string) (string, error) {
@@ -275,156 +273,3 @@ func cleanAssetPath(name string) (string, error) {
} }
return cleaned, nil 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 { if ctxErr := ctx.Err(); ctxErr != nil {
return contracts.StructuredCompletionResponse{}, ctxErr 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() defer prepared.Discard()
preparedDetails := prepared.Details() preparedDetails := prepared.Details()
@@ -169,7 +169,7 @@ func (c *PromptKitClient) CompleteStructured(ctx context.Context, req contracts.
redactPromptKitError(err), 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 { if result == nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("run PromptKit prompt %q: %w: empty result", promptID, contracts.ErrInvalidStructuredOutput) 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 { if err == nil {
return nil return nil
} }
return redactedProviderError{err: err} return sanitizedProviderDiagnostic{message: bearerTokenPattern.ReplaceAllString(err.Error(), "Bearer "+secretReplacement)}
} }
type redactedProviderError struct { type sanitizedProviderDiagnostic struct {
err error message string
} }
func (e redactedProviderError) Error() string { func (e sanitizedProviderDiagnostic) Error() string {
return bearerTokenPattern.ReplaceAllString(e.err.Error(), "Bearer "+secretReplacement) return e.message
}
func (e redactedProviderError) Unwrap() error {
return e.err
} }

View File

@@ -741,6 +741,11 @@ model: local-model
!strings.Contains(err.Error(), promptkit.BackendLocal) { !strings.Contains(err.Error(), promptkit.BackendLocal) {
t.Fatalf("CompleteStructured() without registration error = %v, want preparation failure with local backend context", err) 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 { if providerCalls.Load() != 1 {
t.Fatalf("provider calls after missing-registration failure = %d, want 1", providerCalls.Load()) 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) { 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 var out map[string]any
resp, err := client.CompleteStructured(context.Background(), contracts.StructuredCompletionRequest{ 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]") { if strings.Contains(err.Error(), "secret-token") || !strings.Contains(err.Error(), "Bearer [REDACTED]") {
t.Fatalf("error = %q, want redacted bearer token", err.Error()) 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 { if resp.Debug != nil {
t.Fatalf("debug material = %#v, want none for provider failure without result", resp.Debug) t.Fatalf("debug material = %#v, want none for provider failure without result", resp.Debug)
} }
@@ -1214,6 +1235,12 @@ type fakePromptKitLLM struct {
maxInFlight int32 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) { func (f *fakePromptKitLLM) Generate(ctx context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
f.mu.Lock() f.mu.Lock()
f.last = req f.last = req

View File

@@ -83,6 +83,9 @@ func (s *Scheduler) Run(ctx context.Context, fn func(context.Context) error) err
return err return err
} }
defer release() defer release()
if err := ctx.Err(); err != nil {
return err
}
return fn(ctx) 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) { func waitForAtomicAtLeast(t *testing.T, value *int32, want int32) {
t.Helper() t.Helper()
deadline := time.Now().Add(time.Second) deadline := time.Now().Add(time.Second)

View File

@@ -69,6 +69,7 @@ type artifactCodecEntry struct {
encodeCandidate func(any) ([]byte, error) encodeCandidate func(any) ([]byte, error)
metadata func(any) (map[string]any, error) metadata func(any) (map[string]any, error)
decode func([]byte) (any, error) decode func([]byte) (any, error)
decodeCandidate func([]byte) (any, error)
} }
func NewArtifactCodecRegistry() *ArtifactCodecRegistry { func NewArtifactCodecRegistry() *ArtifactCodecRegistry {
@@ -77,7 +78,7 @@ func NewArtifactCodecRegistry() *ArtifactCodecRegistry {
// RegisterArtifactCodec registers one codec for T. The concrete type is kept // RegisterArtifactCodec registers one codec for T. The concrete type is kept
// private and checked at every erased encode boundary. // 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 { if registry == nil {
return fmt.Errorf("artifact codec registry must not be 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 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) { entry.encodeCandidate = func(value any) ([]byte, error) {
typed, err := exactTypedValue[T]("encode candidate artifact", value) typed, err := exactTypedValue[T]("encode candidate artifact", value)

View File

@@ -14,7 +14,13 @@ import (
) )
type codecNotes struct { 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 { type codecScore struct {
@@ -24,12 +30,13 @@ type codecScore struct {
type codecNotesAlias codecNotes type codecNotesAlias codecNotes
type testArtifactCodec[T any] struct { type testArtifactCodec[T any] struct {
kind contracts.ArtifactKind kind contracts.ArtifactKind
schema contracts.ArtifactSchema schema contracts.ArtifactSchema
mediaType string mediaType string
encodeFunc func(T) ([]byte, error) encodeFunc func(T) ([]byte, error)
candidateFunc func(T) ([]byte, error) candidateFunc func(T) ([]byte, error)
decodeFunc func([]byte) (T, error) decodeFunc func([]byte) (T, error)
candidateDecodeFunc func([]byte) (T, error)
} }
func (c testArtifactCodec[T]) Kind() contracts.ArtifactKind { return c.kind } 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]) 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]) 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.ArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{}
var _ contracts.CandidateArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{}
func TestArtifactCodecRegistryStoresHeterogeneousExactTypes(t *testing.T) { func TestArtifactCodecRegistryStoresHeterogeneousExactTypes(t *testing.T) {
registry := NewArtifactCodecRegistry() 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) { func TestArtifactCodecRegistryStoresValidatedSchemaMetadata(t *testing.T) {
registry := NewArtifactCodecRegistry() registry := NewArtifactCodecRegistry()
codec := notesCodec() codec := notesCodec()
@@ -164,7 +216,7 @@ func TestArtifactCodecRegistryStoresValidatedSchemaMetadata(t *testing.T) {
spec.Schema.JSONSchema[0] = '[' spec.Schema.JSONSchema[0] = '['
again, _ := registry.Spec("test/notes") 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) 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) { func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
shared := []byte(`{"items":["one"]}`) shared := []byte(`{"items":["one"]}`)
codec := notesCodec() codec := notesCodec()
@@ -275,6 +349,10 @@ func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
content[0] = '[' content[0] = '['
return codecNotes{Items: []string{"one"}}, nil return codecNotes{Items: []string{"one"}}, nil
} }
codec.candidateDecodeFunc = func(content []byte) (codecNotes, error) {
content[0] = '['
return codecNotes{Items: []string{"candidate"}}, nil
}
registry := NewArtifactCodecRegistry() registry := NewArtifactCodecRegistry()
if err := RegisterArtifactCodec(registry, codec); err != nil { if err := RegisterArtifactCodec(registry, codec); err != nil {
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err) t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
@@ -295,6 +373,18 @@ func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
if !bytes.Equal(artifact.Content, before) { if !bytes.Equal(artifact.Content, before) {
t.Fatalf("serialized content changed during decode: %q", artifact.Content) 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] { func notesCodec() testArtifactCodec[codecNotes] {
@@ -304,7 +394,7 @@ func notesCodec() testArtifactCodec[codecNotes] {
ID: "notes.v1", ID: "notes.v1",
Name: "notes", Name: "notes",
Version: "v1", 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", mediaType: "application/json",
encodeFunc: func(value codecNotes) ([]byte, error) { encodeFunc: func(value codecNotes) ([]byte, error) {

View File

@@ -17,6 +17,7 @@ import (
"unicode/utf8" "unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "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/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "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) Enabled() bool { return false }
func (noopDebugRecorder) WriteJSON(string, any) error { return nil } func (noopDebugRecorder) WriteJSON(string, any) error { return nil }
func (noopDebugRecorder) WriteBytes(string, []byte) 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 { type debugTimedEnvelope struct {
Stage string `json:"stage,omitempty"` Stage string `json:"stage,omitempty"`
@@ -242,7 +217,7 @@ func (client *debugLLMClient) CompleteStructured(ctx context.Context, req contra
} }
scopePrefix := cleanDebugPath(req.StageName) scopePrefix := cleanDebugPath(req.StageName)
if scopePrefix == "_" { if req.StageName == "" {
scopePrefix = "llm" scopePrefix = "llm"
} }
if scope := debugLLMScopeFromContext(ctx); scope != nil { if scope := debugLLMScopeFromContext(ctx); scope != nil {
@@ -310,7 +285,6 @@ func withDebugLLMScope(ctx context.Context, prefix string) (context.Context, *de
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
prefix = cleanDebugPath(prefix)
scope := &debugLLMScope{ scope := &debugLLMScope{
prefix: prefix, prefix: prefix,
parent: debugLLMScopeFromContext(ctx), parent: debugLLMScopeFromContext(ctx),
@@ -322,7 +296,6 @@ func withIsolatedDebugLLMScope(ctx context.Context, prefix string) (context.Cont
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
prefix = cleanDebugPath(prefix)
scope := &debugLLMScope{prefix: prefix} scope := &debugLLMScope{prefix: prefix}
return context.WithValue(ctx, debugLLMScopeContextKey{}, scope), scope return context.WithValue(ctx, debugLLMScopeContextKey{}, scope), scope
} }
@@ -362,15 +335,12 @@ func (scope *debugLLMScope) references() []debugLLMCallReference {
} }
func cleanDebugPath(value string) string { func cleanDebugPath(value string) string {
parts := strings.Split(path.Clean(strings.TrimSpace(value)), "/") parts := strings.Split(value, "/")
out := make([]string, 0, len(parts)) out := make([]string, 0, len(parts))
for _, part := range parts { for _, part := range parts {
out = append(out, debugPathComponent(part)) out = append(out, fileio.EncodePathComponent(part))
} }
if len(out) == 0 { return strings.Join(out, "/")
return "_"
}
return path.Join(out...)
} }
func debugFirstNonEmptyString(values ...string) string { func debugFirstNonEmptyString(values ...string) string {

View File

@@ -1,13 +1,52 @@
package pipeline package pipeline
import ( import (
"context"
"encoding/json" "encoding/json"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "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) { func TestDebugSourceDocumentPreservesUnitReferences(t *testing.T) {
doc := validSourceDocument() doc := validSourceDocument()
envelope := debugSourceDocumentEnvelope(doc) envelope := debugSourceDocumentEnvelope(doc)

View File

@@ -18,6 +18,16 @@ type referenceTargetKey struct {
Stage ModuleStage Stage ModuleStage
} }
type generatedOutputKey struct {
stepID string
laneID string
}
type indexedGeneratedOutput struct {
count int
output contracts.SerializedOutput
}
func keyForReferenceTarget(target ResolvedReferenceTarget) referenceTargetKey { func keyForReferenceTarget(target ResolvedReferenceTarget) referenceTargetKey {
return referenceTargetKey{StepID: target.StepID, LaneID: target.LaneID, Stage: target.Stage} 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) sets := make(map[referenceTargetKey]contracts.ReferenceSet)
var provenance []artifacts.ReferenceProvenance var provenance []artifacts.ReferenceProvenance
canonical := make(map[string]contracts.ReferenceItem) outputsByProducer := indexGeneratedOutputs(outputs)
canonical := make(map[generatedOutputKey]contracts.ReferenceItem)
for _, prepared := range step.lanes { for _, prepared := range step.lanes {
lane := prepared.resolved lane := prepared.resolved
for _, target := range []ResolvedReferenceTarget{lane.ExtractReferences, lane.MergeReferences, lane.NormalizeReferences} { for _, target := range []ResolvedReferenceTarget{lane.ExtractReferences, lane.MergeReferences, lane.NormalizeReferences} {
@@ -51,7 +62,7 @@ func buildStepReferenceSets(input RunInput, step PreparedPipelineStep, outputs [
continue continue
} }
generated = true generated = true
item, err := generatedReferenceItem(input, binding, outputs, canonical) item, err := generatedReferenceItem(input, binding, outputsByProducer, canonical)
if err != nil { if err != nil {
return nil, nil, contextualHandoffError(input, target, binding, err) 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) 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 selector := binding.Artifact
if selector == nil { if selector == nil {
return contracts.ReferenceItem{}, fmt.Errorf("generated reference selector must not be nil") return contracts.ReferenceItem{}, fmt.Errorf("generated reference selector must not be nil")
} }
stepID := strings.TrimSpace(selector.Step) producerKey := generatedOutputKeyFor(selector.Step, selector.Lane)
laneID := strings.TrimSpace(selector.Lane) if item, ok := cache[producerKey]; ok {
cacheKey := stepID + "\x00" + laneID
if item, ok := cache[cacheKey]; ok {
item.SlotName = strings.TrimSpace(binding.SlotName) item.SlotName = strings.TrimSpace(binding.SlotName)
item.BindingSource = strings.TrimSpace(binding.BindingSource) item.BindingSource = strings.TrimSpace(binding.BindingSource)
return contracts.CloneReferenceItem(item), nil return contracts.CloneReferenceItem(item), nil
} }
matches := make([]contracts.SerializedOutput, 0, 1) matched, ok := outputsByProducer[producerKey]
for _, output := range outputs { if !ok {
if strings.TrimSpace(output.StepID) == stepID && strings.TrimSpace(output.LaneID) == laneID {
matches = append(matches, output)
}
}
if len(matches) == 0 {
return contracts.ReferenceItem{}, fmt.Errorf("producer has no accepted normalized output") return contracts.ReferenceItem{}, fmt.Errorf("producer has no accepted normalized output")
} }
if len(matches) > 1 { if matched.count > 1 {
return contracts.ReferenceItem{}, fmt.Errorf("producer has %d accepted normalized outputs; exactly one is required", len(matches)) 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) producer, ok := findResolvedLane(input.pipeline, stepID, laneID)
if !ok { if !ok {
return contracts.ReferenceItem{}, fmt.Errorf("producer lane is not present in the resolved pipeline") 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 { if input.Prepared == nil || input.Prepared.artifactCodecs == nil {
return contracts.ReferenceItem{}, fmt.Errorf("artifact codec registry is unavailable") 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 { if serialized.Kind != producer.ArtifactKind {
return contracts.ReferenceItem{}, fmt.Errorf("producer artifact kind %q does not match resolved kind %q", 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 := contracts.CloneReferenceItem(item)
cacheItem.SlotName = "" cacheItem.SlotName = ""
cacheItem.BindingSource = "" cacheItem.BindingSource = ""
cache[cacheKey] = cacheItem cache[producerKey] = cacheItem
return item, nil return item, nil
} }
@@ -243,6 +266,7 @@ type generatedReferenceFingerprintIdentity struct {
SchemaDigest string `json:"schema_digest"` SchemaDigest string `json:"schema_digest"`
MediaType string `json:"media_type"` MediaType string `json:"media_type"`
ContentDigest string `json:"content_digest"` ContentDigest string `json:"content_digest"`
SizeBytes int64 `json:"size_bytes"`
} }
// generatedReferenceDependencies returns the canonical semantic dependency // generatedReferenceDependencies returns the canonical semantic dependency
@@ -271,6 +295,7 @@ func generatedReferenceDependencies(set contracts.ReferenceSet) []CheckpointFing
SchemaDigest: contracts.DigestArtifactSchema(item.ArtifactSchema), SchemaDigest: contracts.DigestArtifactSchema(item.ArtifactSchema),
MediaType: item.MediaType, MediaType: item.MediaType,
ContentDigest: item.Digest, ContentDigest: item.Digest,
SizeBytes: item.SizeBytes,
} }
encoded, err := json.Marshal(identity) encoded, err := json.Marshal(identity)
if err != nil { if err != nil {

View File

@@ -3,6 +3,7 @@ package pipeline
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"reflect" "reflect"
"strings" "strings"
"testing" "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) { func TestGeneratedReferenceAcceptsTypedEmptyCollection(t *testing.T) {
input, step, producerOutput := handoffFixture(t, codecNotes{}) input, step, producerOutput := handoffFixture(t, codecNotes{})
sets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{producerOutput}) 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 { {name: "multiple", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
return []contracts.SerializedOutput{output, output} return []contracts.SerializedOutput{output, output}
}, want: "exactly one is required"}, }, 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 { {name: "kind mismatch", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
return []contracts.SerializedOutput{output} return []contracts.SerializedOutput{output}
}, mutate: func(target *ResolvedReferenceTarget, _ *contracts.SerializedOutput) { }, 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) { func TestGeneratedReferenceFingerprintChangesWithCanonicalContent(t *testing.T) {
input, step, first := handoffFixture(t, codecNotes{Items: []string{"first"}}) input, step, first := handoffFixture(t, codecNotes{Items: []string{"first"}})
firstSets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{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) { func TestRunnerHandsOffAcceptedNormalizedOutputBeforeConsumerLanes(t *testing.T) {
input, _, _ := handoffFixture(t, codecNotes{Items: []string{"first"}}) input, _, _ := handoffFixture(t, codecNotes{Items: []string{"first"}})
prepared := input.Prepared prepared := input.Prepared

View File

@@ -305,6 +305,7 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
if name != "extract" { if name != "extract" {
return return
} }
request.Options["nested"].(map[string]any)["value"] = "mutated by extractor builder"
slot := request.References.Slots["extract"] slot := request.References.Slots["extract"]
slot.Items[0].Content = []byte("mutated by extractor builder") slot.Items[0].Content = []byte("mutated by extractor builder")
request.References.Slots["extract"] = slot 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].ExtractReferences.ReferenceSet = constructionReferenceSet("extract", "extract reference")
resolved.Steps[0].ArtifactLanes[0].MergeReferences.ReferenceSet = constructionReferenceSet("merge", "merge 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].NormalizeReferences.ReferenceSet = constructionReferenceSet("normalize", "normalize reference")
resolved.Steps[0].ArtifactLanes[0].Extract.Options = constructionBuildRequest().Options
prepared, err := Prepare(resolved, registries, ModuleDependencies{}) prepared, err := Prepare(resolved, registries, ModuleDependencies{})
if err != nil { 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" { if got := constructionReferenceContent(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" {
t.Fatalf("resolved extract references = %q, want original content", got) 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{ _, 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), 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) { func TestPrepareFailuresOccurBeforeInputParse(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -100,7 +100,7 @@ func Prepare(resolved ResolvedPipeline, registries Registries, deps ModuleDepend
artifactCodecs: registries.ArtifactCodecs, artifactCodecs: registries.ArtifactCodecs,
} }
request := func(binding ModuleBinding, references contracts.ReferenceSet) BuildRequest { 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{})) 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) { func prepareLane(pipeline ResolvedPipeline, lane ResolvedArtifactLane, registries Registries, deps ModuleDependencies) (preparedLaneExecutor, error) {
executor := preparedLaneExecutor{resolved: cloneResolvedArtifactLane(lane)} executor := preparedLaneExecutor{resolved: cloneResolvedArtifactLane(lane)}
request := func(binding ModuleBinding, references contracts.ReferenceSet) BuildRequest { 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) extractEntry, ok := registries.Extractors.typedEntry(lane.Extract.Module)
if !ok { if !ok {
@@ -259,7 +259,7 @@ func prepareValidatorChain(pipeline ResolvedPipeline, registries Registries, dep
resolved := resolvedValidatorChain(stage, laneID, moduleKey, pipeline.ValidatorChains) resolved := resolvedValidatorChain(stage, laneID, moduleKey, pipeline.ValidatorChains)
prepared := preparedValidatorChain{resolved: resolved} prepared := preparedValidatorChain{resolved: resolved}
for _, validator := range resolved.Validators { 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) built, err := buildPreparedValidator(registries.Validators, validator, request)
if err != nil { if err != nil {
return preparedValidatorChain{}, constructionError(pipeline.ID, laneID, stage, moduleKey, validator.Binding.Module, err) return preparedValidatorChain{}, constructionError(pipeline.ID, laneID, stage, moduleKey, validator.Binding.Module, err)
@@ -280,7 +280,7 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida
if !ok { if !ok {
return preparedValidator{}, fmt.Errorf("typed construction entry is not registered") 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.typed = implementation
prepared.typedValidate = entry.validate prepared.typedValidate = entry.validate
case ValidatorTargetChunk: case ValidatorTargetChunk:
@@ -288,14 +288,14 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida
if !ok { if !ok {
return preparedValidator{}, fmt.Errorf("chunk construction entry is not registered") 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 implementation = prepared.chunk
case ValidatorTargetSerialized: case ValidatorTargetSerialized:
entry, ok := registry.serializedEntry(key) entry, ok := registry.serializedEntry(key)
if !ok { if !ok {
return preparedValidator{}, fmt.Errorf("serialized construction entry is not registered") 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 implementation = prepared.serialized
default: default:
return preparedValidator{}, fmt.Errorf("validator construction target %q is not supported", resolved.Target) 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) { 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 { if err != nil {
return nil, err return nil, err
} }

View File

@@ -305,7 +305,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
return ResolvedPipeline{}, err 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 == "" { if input.Module == "" {
return ResolvedPipeline{}, fmt.Errorf("pipeline %q input module must not be empty", pipelineID) 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...) 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) chunkSpec, err := chunkerSpec(catalog, chunk.Module)
if err != nil { if err != nil {
return ResolvedPipeline{}, moduleLookupError(pipelineID, "", StageChunk, chunk.Module, err) return ResolvedPipeline{}, moduleLookupError(pipelineID, "", StageChunk, chunk.Module, err)
@@ -363,6 +369,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
if err != nil { if err != nil {
return ResolvedPipeline{}, err 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{ resolved := ResolvedPipeline{
ID: pipelineID, ID: pipelineID,
Input: input, Input: input,
@@ -370,7 +380,7 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
Chunk: chunk, Chunk: chunk,
ChunkExecutionClass: chunkSpec.ExecutionClass, ChunkExecutionClass: chunkSpec.ExecutionClass,
ChunkReferences: referenceTarget(StageChunk, "", chunk.Module, chunkReferences), ChunkReferences: referenceTarget(StageChunk, "", chunk.Module, chunkReferences),
Output: resolveBinding(profile.Output, DefaultOutputModule), Output: output,
} }
chunkValidatorChain, err := resolveValidatorChain(pipelineID, "", StageChunk, chunk.Module, chunk.Validators, "", nil, catalog) chunkValidatorChain, err := resolveValidatorChain(pipelineID, "", StageChunk, chunk.Module, chunk.Validators, "", nil, catalog)
if err != nil { if err != nil {
@@ -485,13 +495,29 @@ func resolveArtifactLane(
inherited capabilitySet, inherited capabilitySet,
catalog ModuleCatalog, catalog ModuleCatalog,
) (ResolvedArtifactLane, []ResolvedValidatorChain, capabilitySet, error) { ) (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{ lane := ResolvedArtifactLane{
StepID: strings.TrimSpace(stepID), StepID: strings.TrimSpace(stepID),
ID: laneID, ID: laneID,
Extract: resolveBinding(profile.Extract, ""), Extract: extract,
Merge: resolveBinding(profile.Merge, DefaultMergeModule), Merge: merge,
Normalize: resolveBinding(profile.Normalize, DefaultNormalizeModule), Normalize: normalize,
Validators: resolveBindings(profile.Validators, ""), Validators: validators,
} }
if lane.Extract.Module == "" { if lane.Extract.Module == "" {
return ResolvedArtifactLane{}, nil, nil, fmt.Errorf("pipeline %q lane %q extract module must not be empty", pipelineID, laneID) 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) extractReferences := mergeReferenceMaps(profile.References, lane.Extract.References)
references, err := resolveReferenceTargetBindings(referenceResolutionTarget{ references, err := resolveReferenceTargetBindings(referenceResolutionTarget{
PipelineID: pipelineID, PipelineID: pipelineID,
StepID: strings.TrimSpace(stepID),
LaneID: laneID, LaneID: laneID,
Stage: StageExtract, Stage: StageExtract,
Module: lane.Extract.Module, Module: lane.Extract.Module,
@@ -541,6 +568,7 @@ func resolveArtifactLane(
} }
mergeReferences, err := resolveReferenceTargetBindings(referenceResolutionTarget{ mergeReferences, err := resolveReferenceTargetBindings(referenceResolutionTarget{
PipelineID: pipelineID, PipelineID: pipelineID,
StepID: strings.TrimSpace(stepID),
LaneID: laneID, LaneID: laneID,
Stage: StageMerge, Stage: StageMerge,
Module: lane.Merge.Module, Module: lane.Merge.Module,
@@ -566,6 +594,7 @@ func resolveArtifactLane(
} }
normalizeReferences, err := resolveReferenceTargetBindings(referenceResolutionTarget{ normalizeReferences, err := resolveReferenceTargetBindings(referenceResolutionTarget{
PipelineID: pipelineID, PipelineID: pipelineID,
StepID: strings.TrimSpace(stepID),
LaneID: laneID, LaneID: laneID,
Stage: StageNormalize, Stage: StageNormalize,
Module: lane.Normalize.Module, Module: lane.Normalize.Module,
@@ -925,7 +954,7 @@ func validatePipelineReferenceDefaults(
lanesByID map[string]ArtifactLaneProfile, lanesByID map[string]ArtifactLaneProfile,
catalog ModuleCatalog, catalog ModuleCatalog,
) error { ) 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 { if err != nil {
return err return err
} }
@@ -939,38 +968,38 @@ func validatePipelineReferenceDefaults(
} }
for _, laneID := range sortedArtifactLaneProfileKeys(lanesByID) { for _, laneID := range sortedArtifactLaneProfileKeys(lanesByID) {
laneProfile := lanesByID[laneID] laneProfile := lanesByID[laneID]
extract := resolveBinding(laneProfile.Extract, "") extractModule := resolveModuleKey(laneProfile.Extract.Module, "")
if extract.Module == "" { if extractModule == "" {
return fmt.Errorf("pipeline %q lane %q extract module must not be empty", pipelineID, laneID) 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 { if err != nil {
return moduleLookupError(pipelineID, laneID, StageExtract, extract.Module, err) return moduleLookupError(pipelineID, laneID, StageExtract, extractModule, err)
} }
for _, slot := range extractSpec.ReferenceSlots { for _, slot := range extractSpec.ReferenceSlots {
declaredByAnyTarget[slot.Name] = struct{}{} declaredByAnyTarget[slot.Name] = struct{}{}
} }
merge := resolveBinding(laneProfile.Merge, DefaultMergeModule) mergeModule := resolveModuleKey(laneProfile.Merge.Module, DefaultMergeModule)
var artifactType reflect.Type var artifactType reflect.Type
artifactKind := extractSpec.ArtifactKind artifactKind := extractSpec.ArtifactKind
if artifactKind != "" && catalog.Extractors != nil { 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 artifactType = entry.valueType
} }
} }
mergeSpec, err := mergerSpecForArtifact(catalog, merge.Module, artifactKind, artifactType) mergeSpec, err := mergerSpecForArtifact(catalog, mergeModule, artifactKind, artifactType)
if err != nil { if err != nil {
return moduleLookupError(pipelineID, laneID, StageMerge, merge.Module, err) return moduleLookupError(pipelineID, laneID, StageMerge, mergeModule, err)
} }
for _, slot := range mergeSpec.ReferenceSlots { for _, slot := range mergeSpec.ReferenceSlots {
declaredByAnyTarget[slot.Name] = struct{}{} declaredByAnyTarget[slot.Name] = struct{}{}
} }
normalize := resolveBinding(laneProfile.Normalize, DefaultNormalizeModule) normalizeModule := resolveModuleKey(laneProfile.Normalize.Module, DefaultNormalizeModule)
normalizeSpec, err := normalizerSpecForArtifact(catalog, normalize.Module, artifactKind, artifactType) normalizeSpec, err := normalizerSpecForArtifact(catalog, normalizeModule, artifactKind, artifactType)
if err != nil { if err != nil {
return moduleLookupError(pipelineID, laneID, StageNormalize, normalize.Module, err) return moduleLookupError(pipelineID, laneID, StageNormalize, normalizeModule, err)
} }
for _, slot := range normalizeSpec.ReferenceSlots { for _, slot := range normalizeSpec.ReferenceSlots {
declaredByAnyTarget[slot.Name] = struct{}{} declaredByAnyTarget[slot.Name] = struct{}{}
@@ -987,6 +1016,7 @@ func validatePipelineReferenceDefaults(
type referenceResolutionTarget struct { type referenceResolutionTarget struct {
PipelineID string PipelineID string
StepID string
LaneID string LaneID string
Stage ModuleStage Stage ModuleStage
Module string Module string
@@ -1046,7 +1076,7 @@ func resolveReferenceTargetBindings(target referenceResolutionTarget) ([]Referen
return nil 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 { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@@ -1166,6 +1196,9 @@ func sortedReferenceBindings(bindings map[string]ReferenceBinding) []ReferenceBi
func referenceTargetErrorContext(target referenceResolutionTarget) string { func referenceTargetErrorContext(target referenceResolutionTarget) string {
if target.LaneID != "" { 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 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) 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 { func referenceTargetSlotLabel(target referenceResolutionTarget) string {
if target.LaneID != "" { 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 lane %q %s reference slot", target.PipelineID, target.LaneID, target.Stage)
} }
return fmt.Sprintf("pipeline %q %s reference slot", target.PipelineID, 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 { if len(values) == 0 {
return nil, nil return nil, nil
} }
out := make(map[string]ReferenceSource, len(values)) 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) slotName := strings.TrimSpace(rawSlotName)
if slotName == "" { if slotName == "" {
return nil, fmt.Errorf("%s must not be empty", keyName) return nil, fmt.Errorf("%s must not be empty", keyName)
@@ -1236,20 +1274,29 @@ func sortedReferenceBindingKeys(values map[string]ReferenceBinding) []string {
return keys return keys
} }
func resolveBinding(binding ModuleBinding, defaultModule string) ModuleBinding { func resolveBinding(binding ModuleBinding, defaultModule string, referenceSlotLabel string) (ModuleBinding, error) {
module := strings.TrimSpace(binding.Module) module := resolveModuleKey(binding.Module, defaultModule)
if module == "" {
module = defaultModule
}
llmProfile := strings.TrimSpace(binding.LLMProfile) llmProfile := strings.TrimSpace(binding.LLMProfile)
references, err := normalizeReferenceMap(binding.References, referenceSlotLabel)
if err != nil {
return ModuleBinding{}, err
}
return ModuleBinding{ return ModuleBinding{
Module: module, Module: module,
LLMProfile: llmProfile, LLMProfile: llmProfile,
Retries: binding.Retries, Retries: binding.Retries,
Options: cloneOptions(binding.Options), Options: cloneOptions(binding.Options),
References: normalizeReferenceMap(binding.References), References: references,
Validators: cloneValidatorOverride(binding.Validators), 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 { func applyEffectiveLLMProfiles(resolved *ResolvedPipeline, pipelineProfile, overrideProfile string) error {
@@ -1312,17 +1359,20 @@ func applyEffectiveLLMProfiles(resolved *ResolvedPipeline, pipelineProfile, over
return nil return nil
} }
func resolveBindings(bindings []ModuleBinding, defaultModule string) []ModuleBinding { func resolveBindings(bindings []ModuleBinding, defaultModule string, referenceSlotLabel string) ([]ModuleBinding, error) {
if len(bindings) == 0 { if len(bindings) == 0 {
return nil return nil, nil
} }
resolved := make([]ModuleBinding, 0, len(bindings)) resolved := make([]ModuleBinding, 0, len(bindings))
for _, binding := range bindings { for index, binding := range bindings {
resolvedBinding := resolveBinding(binding, defaultModule) resolvedBinding, err := resolveBinding(binding, defaultModule, fmt.Sprintf("%s %d", referenceSlotLabel, index))
if err != nil {
return nil, err
}
resolved = append(resolved, resolvedBinding) resolved = append(resolved, resolvedBinding)
} }
return resolved return resolved, nil
} }
func cloneOptions(options map[string]any) map[string]any { 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) { func selectedArtifactLanes(pipelineID string, artifacts map[string]ArtifactLaneProfile, options ResolveOptions) (map[string]ArtifactLaneProfile, []string, error) {
lanesByID := make(map[string]ArtifactLaneProfile, len(artifacts)) lanesByID := make(map[string]ArtifactLaneProfile, len(artifacts))
for rawLaneID, lane := range 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) { func TestResolvePipelineAppliesEffectiveLLMProfiles(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name string name string

View File

@@ -3,7 +3,10 @@ package pipeline
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"io"
"math"
"mime" "mime"
"net/url" "net/url"
"os" "os"
@@ -124,10 +127,30 @@ func materializeReferenceTarget(
if err != nil { if err != nil {
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q: %w", referenceTargetContext(pipelineID, target), slotName, binding.Source, err) 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 { if err != nil {
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q read %q: %w", referenceTargetContext(pipelineID, target), slotName, path, err) 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) { 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) 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) { 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) 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 { if len(content) == 0 {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: referenceWarningScope(pipelineID, target, slotName), Scope: referenceWarningScope(pipelineID, target, slotName),
@@ -163,6 +183,32 @@ func materializeReferenceTarget(
return set, warnings, nil 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) { func referenceTargetSpec(target ResolvedReferenceTarget, artifactKind contracts.ArtifactKind, catalog ModuleCatalog) (ModuleSpec, error) {
switch target.Stage { switch target.Stage {
case StageChunk: case StageChunk:

View File

@@ -2,6 +2,9 @@ package pipeline
import ( import (
"encoding/json" "encoding/json"
"errors"
"io"
"math"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
@@ -12,6 +15,61 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "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) { func TestMaterializeReferencesResolvesPathsAndDigestsContent(t *testing.T) {
configDir := t.TempDir() configDir := t.TempDir()
workingDir := t.TempDir() workingDir := t.TempDir()

View File

@@ -15,6 +15,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "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/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap" "gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "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 { if r == nil {
return output, fmt.Errorf("runner must not be 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 { if err := validateRunInput(input); err != nil {
return output, err return output, err
} }
@@ -164,6 +168,10 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
if metadataErr != nil { if metadataErr != nil {
return failOutput(output), fmt.Errorf("clone input adapter metadata: %w", metadataErr) 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{ doc, err = adapter.Parse(ctx, contracts.ParseRequest{
SourceID: input.SourceID, SourceID: input.SourceID,
Path: input.Path, Path: input.Path,
@@ -171,6 +179,10 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
LLMProfile: input.pipeline.Input.LLMProfile, LLMProfile: input.pipeline.Input.LLMProfile,
Metadata: requestMetadata, Metadata: requestMetadata,
}) })
if ctxErr := ctx.Err(); ctxErr != nil {
_ = checkpoints.SourceFailed(adapter.Key(), ctxErr)
return failOutput(output), ctxErr
}
if err != nil { if err != nil {
_ = checkpoints.SourceFailed(adapter.Key(), err) _ = checkpoints.SourceFailed(adapter.Key(), err)
return failOutput(output), fmt.Errorf("parse input with adapter %q: %w", 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 { if chunkResult.rejection != nil {
output.Rejected = append(output.Rejected, *chunkResult.rejection) 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{ chunkDebugPayload := map[string]any{
"cache_mode": chunkMode, "cache_mode": chunkMode,
"lookup": chunkResult.lookup, "lookup": chunkResult.lookup,
@@ -280,6 +290,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
return failOutput(output), err return failOutput(output), err
} }
} }
if err := ctx.Err(); err != nil {
return failOutput(output), err
}
if len(output.Rejected) > 0 { if len(output.Rejected) > 0 {
output.Manifest.ValidationStatus = "rejected" 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 { if err := attachModuleManifestMetadata(&output, "output", encoder); err != nil {
return failOutput(output), err return failOutput(output), err
} }
if err := ctx.Err(); err != nil {
return failOutput(output), err
}
outputStarted := time.Now().UTC() outputStarted := time.Now().UTC()
evidenceArtifact, evidenceSummary, err := buildOutputEvidenceContext(input.Prepared, doc, output.NormalizeOutputs) evidenceArtifact, evidenceSummary, err := buildOutputEvidenceContext(input.Prepared, doc, output.NormalizeOutputs)
if err != nil { if err != nil {
return failOutput(output), err return failOutput(output), err
} }
if err := ctx.Err(); err != nil {
return failOutput(output), err
}
if evidenceSummary != nil { if evidenceSummary != nil {
if err := writeDebugTimed(debugRecorder, "output/evidence-context.json", debugTimedEnvelope{ if err := writeDebugTimed(debugRecorder, "output/evidence-context.json", debugTimedEnvelope{
Stage: string(StageOutput), Stage: string(StageOutput),
@@ -307,6 +326,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
}); err != nil { }); err != nil {
return failOutput(output), fmt.Errorf("write evidence context debug artifact: %w", err) 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{ outputDebugPayload := map[string]any{
"manifest": output.Manifest, "manifest": output.Manifest,
@@ -327,10 +349,16 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
}); err != nil { }); err != nil {
return failOutput(output), fmt.Errorf("write output debug artifact: %w", err) 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) outputMetadata, err := cloneMetadata(input.Metadata)
if err != nil { if err != nil {
return failOutput(output), fmt.Errorf("clone output encoder metadata: %w", err) 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{ encoded, err := encoder.Encode(ctx, contracts.OutputRequest{
Manifest: output.Manifest, Manifest: output.Manifest,
NormalizeOutputs: cloneSerializedOutputs(output.NormalizeOutputs), NormalizeOutputs: cloneSerializedOutputs(output.NormalizeOutputs),
@@ -341,7 +369,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
ChunkMap: contracts.CloneSerializedArtifactPointer(acceptedChunkMap), ChunkMap: contracts.CloneSerializedArtifactPointer(acceptedChunkMap),
EvidenceContext: contracts.CloneSerializedArtifactPointer(evidenceArtifact), EvidenceContext: contracts.CloneSerializedArtifactPointer(evidenceArtifact),
}) })
output.Warnings = append(output.Warnings, encoded.Warnings...) if ctxErr := ctx.Err(); ctxErr != nil {
return failOutput(output), ctxErr
}
if err != nil { if err != nil {
return failOutput(output), fmt.Errorf("encode output with encoder %q: %w", encoder.Key(), err) 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 { if err != nil {
return failOutput(output), fmt.Errorf("validate output files from encoder %q: %w", encoder.Key(), err) 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{ if err := writeDebugTimed(debugRecorder, "output/output.json", debugTimedEnvelope{
Stage: string(StageOutput), Stage: string(StageOutput),
ModuleKey: encoder.Key(), ModuleKey: encoder.Key(),
@@ -361,6 +390,11 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
}); err != nil { }); err != nil {
return failOutput(output), fmt.Errorf("write output debug artifact: %w", err) 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 return output, nil
} }
@@ -386,45 +420,52 @@ func (r *Runner) runPreparedSteps(ctx context.Context, input RunInput, checkpoin
return nil 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 attempts := retries + 1
var last *contracts.RejectedOutput var last retryAttemptResult
for attempt := 1; attempt <= attempts; attempt++ { for attempt := 1; attempt <= attempts; attempt++ {
if err := ctx.Err(); err != nil { 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 { if err != nil {
var debugErr *attemptDebugPersistenceError var debugErr *attemptDebugPersistenceError
if errors.As(err, &debugErr) { 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 { 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 { if ctxErr := ctx.Err(); ctxErr != nil {
return false, nil, ctxErr return retryAttemptResult{}, ctxErr
} }
continue continue
} }
if accepted { if result.accepted {
return true, nil, nil return result, nil
} }
if rejection != nil { if result.rejection != nil {
rejection := *result.rejection
rejection.AttemptCount = attempt rejection.AttemptCount = attempt
last = rejection last = retryAttemptResult{rejection: &rejection, warnings: cloneWarnings(result.warnings)}
} }
if ctxErr := ctx.Err(); ctxErr != nil { if ctxErr := ctx.Err(); ctxErr != nil {
return false, nil, ctxErr return retryAttemptResult{}, ctxErr
} }
if attempt == attempts { if attempt == attempts {
if last == nil { if last.rejection == nil {
last = &contracts.RejectedOutput{ReasonCode: "output_rejected", Message: "output rejected", AttemptCount: attempt} 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) { 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 { for index, item := range prepared.validators {
binding := item.resolved.Binding binding := item.resolved.Binding
started := time.Now().UTC() 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) validatorCtx, llmScope := withIsolatedDebugLLMScope(ctx, attemptPath)
var result contracts.ValidationResult var result contracts.ValidationResult
requestMetadata, cloneErr := cloneMetadata(metadata) requestMetadata, cloneErr := cloneMetadata(metadata)

View File

@@ -351,7 +351,7 @@ func TestRunnerKeepsValidatorLLMCallsOutOfModuleAttempt(t *testing.T) {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
moduleEnvelope := debug.envelope(t, "merge/notes/attempt-01.json") 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) validatorEnvelope := debug.envelope(t, validatorPath)
if len(moduleEnvelope.LLMCalls) != 1 || !strings.Contains(moduleEnvelope.LLMCalls[0].ResponsePath, "merge/notes/attempt-01/") { 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) t.Fatalf("module LLM calls = %#v, want module call only", moduleEnvelope.LLMCalls)

View File

@@ -2,6 +2,7 @@ package pipeline
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -13,10 +14,12 @@ import (
) )
type observedNotesCodec struct { type observedNotesCodec struct {
candidateValues []codecNotes candidateValues []codecNotes
finalValues []codecNotes candidateDecodedValues []codecNotes
candidateError string finalValues []codecNotes
finalError string candidateError string
candidateDecodeError string
finalError string
} }
func (*observedNotesCodec) Kind() contracts.ArtifactKind { return "test/notes" } 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) 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 { func firstNote(value codecNotes) string {
if len(value.Items) == 0 { if len(value.Items) == 0 {
return "" return ""
@@ -64,6 +79,10 @@ func (c *observedNotesCodec) finalCalls(value string) int {
return matchingNotes(c.finalValues, value) return matchingNotes(c.finalValues, value)
} }
func (c *observedNotesCodec) candidateDecodeCount() int {
return len(c.candidateDecodedValues)
}
func matchingNotes(values []codecNotes, value string) int { func matchingNotes(values []codecNotes, value string) int {
count := 0 count := 0
for _, candidate := range values { for _, candidate := range values {
@@ -110,6 +129,16 @@ func installObservedNotesCodec(t *testing.T, prepared *PreparedPipeline, codec *
func configureCandidateOperation(prepared *PreparedPipeline, target ModuleStage, value codecNotes) { func configureCandidateOperation(prepared *PreparedPipeline, target ModuleStage, value codecNotes) {
lane := &prepared.Steps[0].lanes[0] lane := &prepared.Steps[0].lanes[0]
switch target { 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: case StageMerge:
lane.typed.merge = func(context.Context, any, contracts.TypedMergeRequest[any]) (erasedTypedResult, error) { lane.typed.merge = func(context.Context, any, contracts.TypedMergeRequest[any]) (erasedTypedResult, error) {
return erasedTypedResult{Value: value}, nil return erasedTypedResult{Value: value}, nil
@@ -135,6 +164,8 @@ func setCandidateValidator(prepared *PreparedPipeline, target ModuleStage, appro
}, },
} }
switch target { switch target {
case StageExtract:
prepared.Steps[0].lanes[0].extractValidators.validators = []preparedValidator{validator}
case StageMerge: case StageMerge:
prepared.Steps[0].lanes[0].mergeValidators.validators = []preparedValidator{validator} prepared.Steps[0].lanes[0].mergeValidators.validators = []preparedValidator{validator}
case StageNormalize: 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) { func TestRunnerRejectsCandidatesBeforeFinalEncoding(t *testing.T) {
for _, target := range []ModuleStage{StageMerge, StageNormalize} { for _, target := range []ModuleStage{StageMerge, StageNormalize} {
t.Run(string(target), func(t *testing.T) { 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 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() attemptStarted := time.Now().UTC()
attemptPath := path.Join("chunk", fmt.Sprintf("attempt-%02d", attempt)) attemptPath := path.Join("chunk", fmt.Sprintf("attempt-%02d", attempt))
attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath) attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath)
terminal := newAttemptTerminalRecorder(input.Debug, attemptPath, "chunk", llmScope, debugTimedEnvelope{Stage: string(StageChunk), ModuleKey: chunker.Key(), Attempt: attempt, StartedAt: attemptStarted}) terminal := newAttemptTerminalRecorder(input.Debug, attemptPath, "chunk", llmScope, debugTimedEnvelope{Stage: string(StageChunk), ModuleKey: chunker.Key(), Attempt: attempt, StartedAt: attemptStarted})
requestMetadata, metadataErr := cloneMetadata(input.Metadata) requestMetadata, metadataErr := cloneMetadata(input.Metadata)
if metadataErr != nil { 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{ chunkResult, callErr := chunker.Plan(attemptCtx, contracts.ChunkRequest{
Source: doc, SourceInput: sourceInput.Clone(), SessionID: sessionID, 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, LLMProfile: input.pipeline.Chunk.LLMProfile, Metadata: requestMetadata,
}) })
if callErr != nil { 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) plan, chunks, validationErr := validateAndMaterializeChunkPlan(doc, chunkResult.Plan)
if validationErr != nil { if validationErr != nil {
attemptErr := fmt.Errorf("validate chunk plan from chunker %q: %w", chunker.Key(), validationErr) 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)} 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) planDigest, digestErr := source.DigestChunkPlan(plan)
if digestErr != nil { 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) producerMetadata, _, metadataErr := moduleManifestMetadata(chunker)
if metadataErr != nil { 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 := "" profile := ""
if input.pipeline.ChunkExecutionClass == contracts.ExecutionClassLLMBacked { if input.pipeline.ChunkExecutionClass == contracts.ExecutionClassLLMBacked {
@@ -137,7 +137,7 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
action = "bypassed" action = "bypassed"
} }
if candidateErr := result.setCandidate(candidate, action); candidateErr != nil { 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) 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...) 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), "plan": debugChunkPlanEnvelope(plan), "materialized_chunks": debugSourceChunkEnvelopes(chunks),
"warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected), "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected),
} }
if validationErr != nil || rejected != nil { if validationErr != nil {
result.setValidation(validationWarnings, rejected, validationErr) 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.chunks = chunks
result.plan = &plan result.plan = &plan
@@ -155,9 +162,9 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
producerWarnings = cloneWarnings(chunkResult.Warnings) producerWarnings = cloneWarnings(chunkResult.Warnings)
result.setValidation(validationWarnings, nil, nil) result.setValidation(validationWarnings, nil, nil)
if debugErr := terminal.record(payload, nil); debugErr != 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 err != nil {
if result.summary.ValidationStatus == "not_run" { 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 return result, err
} }
result.accepted = accepted result.accepted = retryResult.accepted
result.rejection = rejection result.rejection = retryResult.rejection
if !accepted { if !retryResult.accepted {
result.warnings = cloneWarnings(retryResult.warnings)
return result, nil 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) 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) { func TestRunnerFailsGeneratedHandoffBeforeConsumerExtraction(t *testing.T) {
prepared := preparedConcurrentPipeline(t, 1) prepared := preparedConcurrentPipeline(t, 1)
input := &countingInputAdapter{InputAdapter: prepared.input} input := &countingInputAdapter{InputAdapter: prepared.input}

View File

@@ -10,6 +10,7 @@ import (
"sync" "sync"
"time" "time"
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "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() { func (e *laneEngine) collect() {
resultChannel := (<-chan extractJobResult)(e.results) resultChannel := (<-chan extractJobResult)(e.results)
for resultChannel != nil || len(e.pending) > 0 || e.completed < e.launched { 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 accepted erasedExtractArtifact
var serialized CheckpointArtifact var serialized CheckpointArtifact
var acceptedWarnings []contracts.Warning 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() 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) 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}) 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) requestMetadata, metadataErr := cloneMetadata(input.Metadata)
if metadataErr != nil { 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) 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}) 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 { if callErr != nil {
attemptErr := fmt.Errorf("extract lane %q chunk %q with extractor %q: %w", lane.ID, chunk.ID, lane.Extract.Module, callErr) 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} 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) attemptWarnings := cloneWarnings(extracted.Warnings)
@@ -427,31 +431,38 @@ func (r *Runner) runExtractJob(ctx context.Context, input RunInput, doc *source.
if encodeErr != nil { if encodeErr != nil {
attemptErr := fmt.Errorf("serialize extract candidate for lane %q chunk %q: %w", lane.ID, chunk.ID, encodeErr) attemptErr := fmt.Errorf("serialize extract candidate for lane %q chunk %q: %w", lane.ID, chunk.ID, encodeErr)
payload := map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)} 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 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) 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...) attemptWarnings = append(attemptWarnings, warnings...)
payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected)} payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected)}
if validateErr != nil || rejected != nil { if validateErr != nil {
return false, rejected, terminal.record(payload, validateErr) 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) stored, encodeErr := checkpointArtifact(typed.codec, artifact.LaneID, artifact.ExtractorKey, artifact.SourceID, artifact.Value)
if encodeErr != nil { if encodeErr != nil {
attemptErr := fmt.Errorf("serialize accepted extract output for lane %q chunk %q: %w", lane.ID, chunk.ID, encodeErr) 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 stored.ChunkID, stored.ChunkIndex, stored.ChunkRef = artifact.ChunkID, artifact.ChunkIndex, artifact.ChunkRef
accepted, serialized = artifact, stored accepted, serialized = artifact, stored
acceptedWarnings = attemptWarnings acceptedWarnings = attemptWarnings
if debugErr := terminal.record(payload, nil); debugErr != 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
}) })
result.err = err result.err = err
if err == nil && !ok { if err == nil && !retryResult.accepted {
result.rejected = rejection result.rejected = retryResult.rejection
result.warnings = cloneWarnings(retryResult.warnings)
return result return result
} }
result.value, result.serialized, result.warnings = accepted, serialized, acceptedWarnings 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] result := state.results[index]
if result.rejected != nil { if result.rejected != nil {
state.rejected = append(state.rejected, *result.rejected) state.rejected = append(state.rejected, *result.rejected)
state.warnings = append(state.warnings, result.warnings...)
continue continue
} }
state.values = append(state.values, result.value) 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.Warnings = append(local.Warnings, cloneWarnings(results.warnings)...)
local.Rejected = append(local.Rejected, cloneRejectedOutputs(results.rejected)...) 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} 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} return local, &laneRunError{stage: StageExtract, err: err}
} }
if len(results.accepted) == 0 { 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) t.Fatalf("Run() error = %v, want nil", err)
} }
module := debug.envelope(t, "extract/notes/chunk-000001/attempt-01.json") 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/") { 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) t.Fatalf("module LLM calls = %#v, want extract module call only", module.LLMCalls)
} }

View File

@@ -8,8 +8,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"path" "path"
"reflect"
"time" "time"
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "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 return stageResult, err
} }
mergeDecision = mergeResolution.decision 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 return stageResult, err
} }
var merged erasedMergeArtifact 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 { if err := checkpointMergeRunning(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps); err != nil {
return stageResult, err 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() 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) 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}) 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) requestMetadata, metadataErr := cloneMetadata(input.Metadata)
if metadataErr != nil { 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}) 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 { if callErr != nil {
attemptErr := fmt.Errorf("merge lane %q with merger %q: %w", lane.ID, lane.Merge.Module, callErr) 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} candidate := erasedMergeArtifact{LaneID: lane.ID, MergerKey: lane.Merge.Module, SourceID: doc.ID, Value: result.Value}
attemptWarnings := cloneWarnings(result.Warnings) attemptWarnings := cloneWarnings(result.Warnings)
serializedCandidate, encodeErr := serializeCandidateArtifact(typed.codec, candidate.LaneID, candidate.MergerKey, candidate.SourceID, candidate.Value) serializedCandidate, encodeErr := serializeCandidateArtifact(typed.codec, candidate.LaneID, candidate.MergerKey, candidate.SourceID, candidate.Value)
if encodeErr != nil { if encodeErr != nil {
attemptErr := fmt.Errorf("serialize merge candidate for lane %q: %w", lane.ID, encodeErr) 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) 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...) attemptWarnings = append(attemptWarnings, warnings...)
payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected)} payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected)}
if validateErr != nil || rejected != nil { if validateErr != nil {
return false, rejected, terminal.record(payload, validateErr) 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) stored, encodeErr := checkpointArtifact(typed.codec, candidate.LaneID, candidate.MergerKey, candidate.SourceID, candidate.Value)
if encodeErr != nil { if encodeErr != nil {
attemptErr := fmt.Errorf("serialize accepted merge output for lane %q: %w", lane.ID, encodeErr) 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 { if debugErr := terminal.record(payload, nil); debugErr != nil {
return false, nil, debugErr return retryAttemptResult{}, debugErr
} }
merged, serializedMerge = candidate, stored merged, serializedMerge = candidate, stored
mergeWarnings = attemptWarnings mergeWarnings = attemptWarnings
return true, nil, nil return retryAttemptResult{accepted: true}, nil
}) })
if runErr != nil { if runErr != nil {
_ = checkpointMergeFailed(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps, runErr) _ = checkpointMergeFailed(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps, runErr)
return stageResult, runErr return stageResult, runErr
} }
if !ok { if !retryResult.accepted {
output.Rejected = append(output.Rejected, *rejection) output.Warnings = append(output.Warnings, retryResult.warnings...)
if err := checkpointMergeRejected(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps, *rejection); err != nil { 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 return stageResult, err
} }
stageResult.terminal = true stageResult.terminal = true
@@ -303,7 +312,7 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints
return stageResult, err 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 return stageResult, err
} }
stageResult.artifact = merged stageResult.artifact = merged
@@ -329,7 +338,7 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
return stageResult, err return stageResult, err
} }
normalizeDecision = normalizeResolution.decision 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 return stageResult, err
} }
var serializedNormalize CheckpointArtifact 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 { if err := checkpointNormalizeRunning(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps); err != nil {
return stageResult, err 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() 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) 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}) 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) requestMetadata, metadataErr := cloneMetadata(input.Metadata)
if metadataErr != nil { 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}) 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 { if callErr != nil {
attemptErr := fmt.Errorf("normalize lane %q with normalizer %q: %w", lane.ID, lane.Normalize.Module, callErr) 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) attemptWarnings := cloneWarnings(result.Warnings)
serializedCandidate, encodeErr := serializeCandidateArtifact(typed.codec, lane.ID, lane.Normalize.Module, doc.ID, result.Value) serializedCandidate, encodeErr := serializeCandidateArtifact(typed.codec, lane.ID, lane.Normalize.Module, doc.ID, result.Value)
if encodeErr != nil { if encodeErr != nil {
attemptErr := fmt.Errorf("serialize normalize candidate for lane %q: %w", lane.ID, encodeErr) 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 var retryPayload map[string]any
if result.Retry != nil { if result.Retry != nil {
if err := validateNormalizeRetry(result.Retry); err != 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 retryRemaining := attempt <= lane.Normalize.Retries
retryPayload = map[string]any{ retryPayload = map[string]any{
@@ -376,7 +385,7 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
} }
if retryRemaining { if retryRemaining {
payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "retry": retryPayload} 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)...) attemptWarnings = append(attemptWarnings, cloneWarnings(result.Retry.FallbackWarnings)...)
} }
@@ -386,28 +395,35 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
if retryPayload != nil { if retryPayload != nil {
payload["retry"] = retryPayload payload["retry"] = retryPayload
} }
if validateErr != nil || rejected != nil { if validateErr != nil {
return false, rejected, terminal.record(payload, validateErr) 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) stored, encodeErr := checkpointArtifact(typed.codec, lane.ID, lane.Normalize.Module, doc.ID, result.Value)
if encodeErr != nil { if encodeErr != nil {
attemptErr := fmt.Errorf("serialize accepted normalize output for lane %q: %w", lane.ID, encodeErr) 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 { if debugErr := terminal.record(payload, nil); debugErr != nil {
return false, nil, debugErr return retryAttemptResult{}, debugErr
} }
serializedNormalize = stored serializedNormalize = stored
normalizeWarnings = attemptWarnings normalizeWarnings = attemptWarnings
return true, nil, nil return retryAttemptResult{accepted: true}, nil
}) })
if runErr != nil { if runErr != nil {
_ = checkpointNormalizeFailed(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps, runErr) _ = checkpointNormalizeFailed(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps, runErr)
return stageResult, runErr return stageResult, runErr
} }
if !ok { if !retryResult.accepted {
output.Rejected = append(output.Rejected, *rejection) output.Warnings = append(output.Warnings, retryResult.warnings...)
if err := checkpointNormalizeRejected(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps, *rejection); err != nil { 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, err
} }
return stageResult, nil return stageResult, nil
@@ -417,7 +433,7 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
return stageResult, err 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 return stageResult, err
} }
stageResult.serialized = serializedNormalize stageResult.serialized = serializedNormalize
@@ -469,7 +485,7 @@ func (r *Runner) validateTypedArtifact(ctx context.Context, codec artifactCodecE
var result contracts.ValidationResult var result contracts.ValidationResult
var err error var err error
started := time.Now().UTC() 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) validatorCtx, llmScope := withIsolatedDebugLLMScope(ctx, attemptPath)
requestTarget := target requestTarget := target
requestTarget.sourceInput = target.sourceInput.Clone() requestTarget.sourceInput = target.sourceInput.Clone()
@@ -488,6 +504,12 @@ func (r *Runner) validateTypedArtifact(ctx context.Context, codec artifactCodecE
} }
switch item.resolved.Target { switch item.resolved.Target {
case ValidatorTargetTyped: 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 requestTarget.llmProfile = binding.LLMProfile
result, err = item.typedValidate(validatorCtx, item.typed, requestTarget) result, err = item.typedValidate(validatorCtx, item.typed, requestTarget)
case ValidatorTargetSerialized: case ValidatorTargetSerialized:
@@ -547,3 +569,15 @@ func validationCandidateArtifact(codec artifactCodecEntry, target typedValidatio
} }
return serializeCandidateArtifact(codec, target.laneID, target.moduleKey, target.sourceID, target.value) 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() t.Helper()
if err := RegisterArtifactCodec(registry, codec); err != nil { if err := RegisterArtifactCodec(registry, codec); err != nil {
t.Fatalf("RegisterArtifactCodec() error = %v", err) t.Fatalf("RegisterArtifactCodec() error = %v", err)

View File

@@ -71,7 +71,11 @@ func normalizeValidatorChainMapping(mapping ValidatorChainMapping) (ValidatorCha
if strings.TrimSpace(validator.Module) == "" { 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) 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 return normalized, nil
} }

View File

@@ -150,7 +150,13 @@ func RegisterChunkValidatorBuilder(registry *ValidatorRegistry, spec ValidatorSp
if registry.chunkEntries == nil { if registry.chunkEntries == nil {
registry.chunkEntries = make(map[string]chunkValidatorEntry) 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 return nil
} }
@@ -187,7 +193,13 @@ func RegisterSerializedValidatorBuilder(registry *ValidatorRegistry, spec Serial
if registry.serializedEntries == nil { if registry.serializedEntries == nil {
registry.serializedEntries = make(map[string]serializedValidatorEntry) 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 return nil
} }

View File

@@ -1,14 +1,12 @@
package promptfs package promptfs
import ( import (
"bytes"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"path" "path"
"sort"
"strings" "strings"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/readonlyfs"
) )
// ModulePromptFile maps a module-owned embedded prompt file into the registered // 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 { if moduleFS == nil {
return nil, fmt.Errorf("module prompt filesystem must not be 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 { for _, file := range files {
name, err := cleanPromptPath(file.Name) name, err := cleanPromptPath(file.Name)
if err != nil { if err != nil {
@@ -46,6 +45,11 @@ func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile,
if strings.Contains(name, "/") { if strings.Contains(name, "/") {
return nil, fmt.Errorf("module prompt file name %q must not contain path separators", file.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) filePath, err := cleanPromptPath(file.Path)
if err != nil { if err != nil {
return nil, fmt.Errorf("module prompt file path %q: %w", file.Path, err) 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 { if err != nil {
return nil, fmt.Errorf("read module prompt asset %s: %w", filePath, err) 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 { for _, file := range sharedFiles {
name, err := cleanPromptPath(file.Name) name, err := cleanPromptPath(file.Name)
if err != nil { if err != nil {
@@ -65,6 +70,11 @@ func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile,
if strings.Contains(name, "/") { if strings.Contains(name, "/") {
return nil, fmt.Errorf("shared prompt file name %q must not contain path separators", file.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 { if file.FS == nil {
return nil, fmt.Errorf("shared prompt file %q filesystem must not be nil", file.Name) 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 { if err != nil {
return nil, fmt.Errorf("read shared prompt asset %s: %w", filePath, err) 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 fsys, err := readonlyfs.New(assets)
}
type promptMapFS map[string][]byte
func (m promptMapFS) Open(name string) (fs.File, error) {
cleaned, err := cleanPromptFSPath(name)
if err != nil { 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 fsys, nil
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
} }
func cleanPromptPath(name string) (string, error) { func cleanPromptPath(name string) (string, error) {
@@ -160,81 +106,3 @@ func cleanPromptPath(name string) (string, error) {
} }
return cleaned, nil 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) { func TestModulePromptFSRejectsInvalidModuleDir(t *testing.T) {
_, err := ModulePromptFS(".", fstest.MapFS{}, nil) _, err := ModulePromptFS(".", fstest.MapFS{}, nil)
if err == nil || !strings.Contains(err.Error(), "module prompt directory") { 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/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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/candidatejson"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
) )
const ( const (
@@ -21,6 +22,7 @@ const (
var schemaAssets embed.FS var schemaAssets embed.FS
var _ contracts.ArtifactCodec[dnd.CombatTurnList] = (*Codec)(nil) var _ contracts.ArtifactCodec[dnd.CombatTurnList] = (*Codec)(nil)
var _ contracts.CandidateArtifactCodec[dnd.CombatTurnList] = (*Codec)(nil)
type Codec struct{} type Codec struct{}
@@ -97,11 +99,8 @@ func validate(value dnd.CombatTurnList) error {
if strings.TrimSpace(ref.SourceID) == "" { if strings.TrimSpace(ref.SourceID) == "" {
return fmt.Errorf("%s.source_id must not be empty", refPrefix) return fmt.Errorf("%s.source_id must not be empty", refPrefix)
} }
if ref.StartUnitID <= 0 { if err := sourcerange.Validate(ref); err != nil {
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) return fmt.Errorf("%s: %w", refPrefix, err)
}
if ref.EndUnitID <= 0 {
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
} }
} }
} }

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) { func TestCodecRejectsRequiredShapeAndReferenceBoundaries(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -5,11 +5,14 @@ import (
"embed" "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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/candidatejson"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents"
) )
const ( const (
@@ -23,6 +26,7 @@ const (
var schemaAssets embed.FS var schemaAssets embed.FS
var _ contracts.ArtifactCodec[dnd.EnemyEventList] = (*Codec)(nil) var _ contracts.ArtifactCodec[dnd.EnemyEventList] = (*Codec)(nil)
var _ contracts.CandidateArtifactCodec[dnd.EnemyEventList] = (*Codec)(nil)
type Codec struct{} 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) { 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 nil, fmt.Errorf("encode dnd enemy event list: %w", err)
} }
return c.EncodeCandidate(value) return c.EncodeCandidate(value)
@@ -70,6 +74,9 @@ func (c *Codec) Decode(content []byte) (dnd.EnemyEventList, error) {
if err := validateRequiredJSONFields(content); err != nil { if err := validateRequiredJSONFields(content); err != nil {
return dnd.EnemyEventList{}, fmt.Errorf("decode dnd enemy event list: %w", err) 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 return value, nil
} }
@@ -83,13 +90,29 @@ func (c *Codec) DecodeCandidate(content []byte) (dnd.EnemyEventList, error) {
return cloneList(value), nil return cloneList(value), nil
} }
func validateRequiredValueFields(value dnd.EnemyEventList) error { func validate(value dnd.EnemyEventList) error {
if value.Events == nil { if value.Events == nil {
return fmt.Errorf("events must be present") return fmt.Errorf("events must be present")
} }
for index, event := range value.Events { for index, event := range value.Events {
if event.SourceRefs == nil { prefix := fmt.Sprintf("events[%d]", index)
return fmt.Errorf("events[%d].source_refs must be present", 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 return nil
@@ -175,7 +198,8 @@ func cloneList(value dnd.EnemyEventList) dnd.EnemyEventList {
for index, event := range value.Events { for index, event := range value.Events {
cloned.Events[index] = event cloned.Events[index] = event
if event.SourceRefs != nil { 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 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 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 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 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"}, {"trailing JSON", `{"events":[]} {}`, "multiple JSON values"},
} }
for _, test := range tests { 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() codec := New()
candidate := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{ candidate := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{
Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{SourceID: "", StartUnitID: 0, EndUnitID: -1}}, Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{SourceID: "", StartUnitID: 0, EndUnitID: -1}},
@@ -82,13 +84,16 @@ func TestCodecDefensivelyOwnsValuesAndDefersSemanticValidation(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
decoded, err := codec.Decode(content) decoded, err := codec.DecodeCandidate(content)
if err != nil || !reflect.DeepEqual(decoded, candidate) { 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" decoded.Events[0].SourceRefs[0].SourceID = "changed"
if candidate.Events[0].SourceRefs[0].SourceID != "" { 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() first := codec.Schema()
@@ -97,3 +102,57 @@ func TestCodecDefensivelyOwnsValuesAndDefersSemanticValidation(t *testing.T) {
t.Fatal("Schema() returned shared bytes") 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/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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/candidatejson"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/itemoccurrences" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/itemoccurrences"
) )
@@ -24,6 +25,7 @@ const (
var schemaAssets embed.FS var schemaAssets embed.FS
var _ contracts.ArtifactCodec[dnd.ItemOccurrenceList] = (*Codec)(nil) var _ contracts.ArtifactCodec[dnd.ItemOccurrenceList] = (*Codec)(nil)
var _ contracts.CandidateArtifactCodec[dnd.ItemOccurrenceList] = (*Codec)(nil)
type Codec struct{} type Codec struct{}
@@ -119,11 +121,8 @@ func validate(value dnd.ItemOccurrenceList) error {
if strings.TrimSpace(ref.SourceID) == "" { if strings.TrimSpace(ref.SourceID) == "" {
return fmt.Errorf("%s.source_id must not be empty", refPrefix) return fmt.Errorf("%s.source_id must not be empty", refPrefix)
} }
if ref.StartUnitID <= 0 { if err := sourcerange.Validate(ref); err != nil {
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) return fmt.Errorf("%s: %w", refPrefix, err)
}
if ref.EndUnitID <= 0 {
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
} }
} }
} }

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) { 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}]}]}` 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 { tests := []struct {
@@ -124,14 +159,29 @@ func TestCodecDeepCopiesBoundaryValuesAndMetadata(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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] { 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") 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].Quantity = 99
decoded.Occurrences[1].SourceRefs[0].SourceID = "changed" decoded.Occurrences[1].SourceRefs[0].SourceID = "changed"
if *value.Occurrences[1].Quantity != 12 || value.Occurrences[1].SourceRefs[0].SourceID != "session" { if *value.Occurrences[1].Quantity != 12 || value.Occurrences[1].SourceRefs[0].SourceID != "session" {
t.Fatal("decoded item occurrence aliases input") 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 := codec.Schema()
first.JSONSchema[0] = '[' first.JSONSchema[0] = '['

View File

@@ -9,6 +9,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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/candidatejson"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
) )
@@ -23,6 +24,7 @@ const (
var schemaAssets embed.FS var schemaAssets embed.FS
var _ contracts.ArtifactCodec[dnd.ItemRegistry] = (*Codec)(nil) var _ contracts.ArtifactCodec[dnd.ItemRegistry] = (*Codec)(nil)
var _ contracts.CandidateArtifactCodec[dnd.ItemRegistry] = (*Codec)(nil)
type Codec struct{} type Codec struct{}
@@ -99,11 +101,8 @@ func validate(value dnd.ItemRegistry) error {
if strings.TrimSpace(ref.SourceID) == "" { if strings.TrimSpace(ref.SourceID) == "" {
return fmt.Errorf("%s.source_id must not be empty", refPrefix) return fmt.Errorf("%s.source_id must not be empty", refPrefix)
} }
if ref.StartUnitID <= 0 { if err := sourcerange.Validate(ref); err != nil {
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) return fmt.Errorf("%s: %w", refPrefix, err)
}
if ref.EndUnitID <= 0 {
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
} }
} }
} }

View File

@@ -59,9 +59,13 @@ func TestCodecOwnsDurableSchemaAndExactType(t *testing.T) {
func TestCodecCandidateEncodingMetadataAndStrictDecode(t *testing.T) { func TestCodecCandidateEncodingMetadataAndStrictDecode(t *testing.T) {
codec := New() codec := New()
candidate := dnd.ItemRegistry{Items: []dnd.Item{{Name: "Silver Key"}}} 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) 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") { if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), "item ID pattern") {
t.Fatalf("Encode() error = %v, want durable validation", err) 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) { func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
codec := New() codec := New()
first := codec.Schema() first := codec.Schema()

View File

@@ -9,6 +9,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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/candidatejson"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
) )
@@ -23,6 +24,7 @@ const (
var schemaAssets embed.FS var schemaAssets embed.FS
var _ contracts.ArtifactCodec[dnd.LocationOccurrenceList] = (*Codec)(nil) var _ contracts.ArtifactCodec[dnd.LocationOccurrenceList] = (*Codec)(nil)
var _ contracts.CandidateArtifactCodec[dnd.LocationOccurrenceList] = (*Codec)(nil)
type Codec struct{} type Codec struct{}
@@ -102,11 +104,8 @@ func validate(value dnd.LocationOccurrenceList) error {
if strings.TrimSpace(ref.SourceID) == "" { if strings.TrimSpace(ref.SourceID) == "" {
return fmt.Errorf("%s.source_id must not be empty", refPrefix) return fmt.Errorf("%s.source_id must not be empty", refPrefix)
} }
if ref.StartUnitID <= 0 { if err := sourcerange.Validate(ref); err != nil {
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) return fmt.Errorf("%s: %w", refPrefix, err)
}
if ref.EndUnitID <= 0 {
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
} }
} }
} }

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) { 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}]}]}` 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 }{ 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/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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/candidatejson"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
) )
@@ -23,6 +24,7 @@ const (
var schemaAssets embed.FS var schemaAssets embed.FS
var _ contracts.ArtifactCodec[dnd.LocationRegistry] = (*Codec)(nil) var _ contracts.ArtifactCodec[dnd.LocationRegistry] = (*Codec)(nil)
var _ contracts.CandidateArtifactCodec[dnd.LocationRegistry] = (*Codec)(nil)
type Codec struct{} type Codec struct{}
@@ -99,11 +101,8 @@ func validate(value dnd.LocationRegistry) error {
if strings.TrimSpace(ref.SourceID) == "" { if strings.TrimSpace(ref.SourceID) == "" {
return fmt.Errorf("%s.source_id must not be empty", refPrefix) return fmt.Errorf("%s.source_id must not be empty", refPrefix)
} }
if ref.StartUnitID <= 0 { if err := sourcerange.Validate(ref); err != nil {
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) return fmt.Errorf("%s: %w", refPrefix, err)
}
if ref.EndUnitID <= 0 {
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
} }
} }
} }

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) { 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}]}]}` 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 }{ 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/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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/candidatejson"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
) )
const ( const (
@@ -22,6 +23,7 @@ const (
var schemaAssets embed.FS var schemaAssets embed.FS
var _ contracts.ArtifactCodec[dnd.NPCOccurrenceList] = (*Codec)(nil) var _ contracts.ArtifactCodec[dnd.NPCOccurrenceList] = (*Codec)(nil)
var _ contracts.CandidateArtifactCodec[dnd.NPCOccurrenceList] = (*Codec)(nil)
type Codec struct{} type Codec struct{}
@@ -101,11 +103,8 @@ func validate(value dnd.NPCOccurrenceList) error {
if strings.TrimSpace(ref.SourceID) == "" { if strings.TrimSpace(ref.SourceID) == "" {
return fmt.Errorf("%s.source_id must not be empty", refPrefix) return fmt.Errorf("%s.source_id must not be empty", refPrefix)
} }
if ref.StartUnitID <= 0 { if err := sourcerange.Validate(ref); err != nil {
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) return fmt.Errorf("%s: %w", refPrefix, err)
}
if ref.EndUnitID <= 0 {
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
} }
} }
} }

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) { 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}]}]}` 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 }{ 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/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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/candidatejson"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
) )
@@ -22,6 +23,7 @@ const (
var schemaAssets embed.FS var schemaAssets embed.FS
var _ contracts.ArtifactCodec[dnd.NPCRegistry] = (*Codec)(nil) var _ contracts.ArtifactCodec[dnd.NPCRegistry] = (*Codec)(nil)
var _ contracts.CandidateArtifactCodec[dnd.NPCRegistry] = (*Codec)(nil)
type Codec struct{} type Codec struct{}
@@ -98,11 +100,8 @@ func validate(value dnd.NPCRegistry) error {
if strings.TrimSpace(ref.SourceID) == "" { if strings.TrimSpace(ref.SourceID) == "" {
return fmt.Errorf("%s.source_id must not be empty", refPrefix) return fmt.Errorf("%s.source_id must not be empty", refPrefix)
} }
if ref.StartUnitID <= 0 { if err := sourcerange.Validate(ref); err != nil {
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) return fmt.Errorf("%s: %w", refPrefix, err)
}
if ref.EndUnitID <= 0 {
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
} }
} }
} }

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) { func TestCodecRejectsEveryRequiredShapeBoundary(t *testing.T) {
base := validList().NPCs[0] base := validList().NPCs[0]
tests := []struct { tests := []struct {

View File

@@ -8,6 +8,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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/candidatejson"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
) )
const ( const (
@@ -21,6 +22,7 @@ const (
var schemaAssets embed.FS var schemaAssets embed.FS
var _ contracts.ArtifactCodec[dnd.SceneDescriptionList] = (*Codec)(nil) var _ contracts.ArtifactCodec[dnd.SceneDescriptionList] = (*Codec)(nil)
var _ contracts.CandidateArtifactCodec[dnd.SceneDescriptionList] = (*Codec)(nil)
type Codec struct{} type Codec struct{}
@@ -101,11 +103,8 @@ func validate(value dnd.SceneDescriptionList) error {
if strings.TrimSpace(scene.SourceRef.SourceID) == "" { if strings.TrimSpace(scene.SourceRef.SourceID) == "" {
return fmt.Errorf("%s.source_ref.source_id must not be empty", prefix) return fmt.Errorf("%s.source_ref.source_id must not be empty", prefix)
} }
if scene.SourceRef.StartUnitID <= 0 { if err := sourcerange.Validate(scene.SourceRef); err != nil {
return fmt.Errorf("%s.source_ref.start_unit_id must be positive", prefix) return fmt.Errorf("%s.source_ref: %w", prefix, err)
}
if scene.SourceRef.EndUnitID <= 0 {
return fmt.Errorf("%s.source_ref.end_unit_id must be positive", prefix)
} }
} }
return nil 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) { func TestCodecRejectsRequiredApprovedValues(t *testing.T) {
tests := []struct { tests := []struct {
name string 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/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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/candidatejson"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange"
) )
const ( const (
@@ -21,6 +22,7 @@ const (
var schemaAssets embed.FS var schemaAssets embed.FS
var _ contracts.ArtifactCodec[dnd.SpellList] = (*Codec)(nil) var _ contracts.ArtifactCodec[dnd.SpellList] = (*Codec)(nil)
var _ contracts.CandidateArtifactCodec[dnd.SpellList] = (*Codec)(nil)
type Codec struct{} type Codec struct{}
@@ -90,11 +92,8 @@ func validate(value dnd.SpellList) error {
if strings.TrimSpace(ref.SourceID) == "" { if strings.TrimSpace(ref.SourceID) == "" {
return fmt.Errorf("spell_casts[%d].source_refs[%d].source_id must not be empty", index, refIndex) return fmt.Errorf("spell_casts[%d].source_refs[%d].source_id must not be empty", index, refIndex)
} }
if ref.StartUnitID <= 0 { if err := sourcerange.Validate(ref); err != nil {
return fmt.Errorf("spell_casts[%d].source_refs[%d].start_unit_id must be positive", index, refIndex) return fmt.Errorf("spell_casts[%d].source_refs[%d]: %w", index, refIndex, err)
}
if ref.EndUnitID <= 0 {
return fmt.Errorf("spell_casts[%d].source_refs[%d].end_unit_id must be positive", index, refIndex)
} }
} }
} }

View File

@@ -114,6 +114,10 @@ func TestCodecEncodesIncompleteCandidateWithoutWeakeningFinalEncoding(t *testing
if !json.Valid(content) { if !json.Valid(content) {
t.Fatalf("EncodeCandidate() = %q, want JSON", 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}) result, err := spellshape.New(spellshape.Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Value: candidate})
if err != nil || result.Approved || result.ReasonCode != spellshape.ReasonCode { if err != nil || result.Approved || result.ReasonCode != spellshape.ReasonCode {
t.Fatalf("shape validation = %#v, %v; want candidate rejection", result, err) 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") { 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) 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) { func TestCodecSchemaIsMutationSafe(t *testing.T) {

View File

@@ -3,6 +3,8 @@
package enemyevents package enemyevents
import ( import (
"encoding/json"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "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 // ExactEqual reports whether events have the same subject identity, kind, and
// complete canonical evidence sequence. // complete canonical evidence sequence.
func ExactEqual(order shared.SourceRefOrder, left, right dnd.EnemyEvent) bool { 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) && return ComparisonKey(left.Name) == ComparisonKey(right.Name) &&
left.Kind == right.Kind && 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 // 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 := extractionRequest(t)
req.Source.Units = []source.SourceUnit{{ID: 30}, {ID: 10}} req.Source.Units = []source.SourceUnit{{ID: 30}, {ID: 10}}
req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...) 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) req.References = sceneReferences(t, req.Chunk, dnd.SceneKindCombat)
result, err := newExtractor(t, client).Extract(context.Background(), req) result, err := newExtractor(t, client).Extract(context.Background(), req)
@@ -265,7 +265,7 @@ func TestExtractAppliesSceneEligibilityBeforePromptConstruction(t *testing.T) {
name: "start mismatch", name: "start mismatch",
scenes: []dnd.SceneDescription{func() dnd.SceneDescription { scenes: []dnd.SceneDescription{func() dnd.SceneDescription {
value := sceneDescription(extractionRequest(t).Chunk, dnd.SceneKindCombat) value := sceneDescription(extractionRequest(t).Chunk, dnd.SceneKindCombat)
value.SourceRef.StartUnitID = 2 value.SourceRef.StartUnitID = 3
return value return value
}()}, }()},
wantWarning: "scene_classification_unavailable", wantWarning: "scene_classification_unavailable",
@@ -275,7 +275,7 @@ func TestExtractAppliesSceneEligibilityBeforePromptConstruction(t *testing.T) {
name: "end mismatch", name: "end mismatch",
scenes: []dnd.SceneDescription{func() dnd.SceneDescription { scenes: []dnd.SceneDescription{func() dnd.SceneDescription {
value := sceneDescription(extractionRequest(t).Chunk, dnd.SceneKindCombat) value := sceneDescription(extractionRequest(t).Chunk, dnd.SceneKindCombat)
value.SourceRef.EndUnitID = 10 value.SourceRef.EndUnitID = 9
return value return value
}()}, }()},
wantWarning: "scene_classification_unavailable", wantWarning: "scene_classification_unavailable",
@@ -340,7 +340,7 @@ func TestSceneEligibilityMetadataAndFingerprintsTrackGatingValues(t *testing.T)
change func(*dnd.SceneDescription) change func(*dnd.SceneDescription)
}{ }{
{"kind", func(value *dnd.SceneDescription) { value.Kind = dnd.SceneKindNarrative }}, {"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) { t.Run(test.name, func(t *testing.T) {
changed := base changed := base
@@ -477,7 +477,7 @@ func extractionRequest(t *testing.T) contracts.TypedExtractionRequest {
ID: "session-alpha:chunk:0", ID: "session-alpha:chunk:0",
SourceID: doc.ID, SourceID: doc.ID,
Index: 0, 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]}`), Content: []byte(`{"units":[10,2]}`),
MediaType: "application/json", MediaType: "application/json",
Units: append([]source.SourceUnit(nil), doc.Units...), 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(`{}`)})}, {"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})}, {"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)})}, {"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) resolver, err := newGroundingResolver(valid)
if err != nil { 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 { func groundingReferences(t *testing.T, enemy string, sceneKind dnd.SceneKind) contracts.ReferenceSet {
t.Helper() t.Helper()
npcContent, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: []dnd.NPC{{ 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)) values := make([]source.SourceRef, len(refs))
for index, ref := range 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 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" { 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) 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] input := client.requests[0].Inputs[ItemRegistryReferenceSlot]
if input.Name != ItemRegistryReferenceSlot || string(input.Content) != `{"items":[{"name":"Torch"}]}` || strings.Contains(string(input.Content), "item:sha256:") { 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) 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) { 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 := extractionRequest()
req.References = itemRegistryReferences(t) req.References = itemRegistryReferences(t)
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)

View File

@@ -14,6 +14,6 @@ type itemOccurrenceResponse struct {
} }
type itemOccurrenceSourceRefResponse struct { type itemOccurrenceSourceRefResponse struct {
StartSegment int `json:"start_segment"` StartUnitID int `json:"start_unit_id"`
EndSegment int `json:"end_segment"` 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" { if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_item_occurrences_llm.v1.json" {
t.Fatalf("prepared prompt = %#v", prepared) 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) { func TestPromptAssetsDoNotLeakIntoMetadata(t *testing.T) {

View File

@@ -20,11 +20,11 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
valid := map[string]any{"occurrences": []any{ valid := map[string]any{"occurrences": []any{
map[string]any{ map[string]any{
"name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party", "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{ map[string]any{
"name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil, "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) 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")}}}, {"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")}}}, {"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 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}})}}}, {"segment-named range", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1}})}}},
{"noninteger range", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1.5, "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) { t.Run(test.name, func(t *testing.T) {
content, err := json.Marshal(test.value) content, err := json.Marshal(test.value)

View File

@@ -44,7 +44,7 @@ func sourceDocument() *source.SourceDocument {
} }
func responseRefs(start, end int) []itemOccurrenceSourceRefResponse { 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 { 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) { func newCatalogPromptInput(effective spellcatalog.EffectiveCatalog) (contracts.LLMInputMaterial, error) {
content, err := json.Marshal(struct { content, err := json.Marshal(struct {
SpellNames []string `json:"spell_names"` Spells []spellcatalog.PromptSpell `json:"spells"`
}{SpellNames: effective.CanonicalNames()}) }{Spells: effective.PromptSpells()})
if err != nil { 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) sum := sha256.Sum256(content)
digest := "sha256:" + hex.EncodeToString(sum[:]) digest := "sha256:" + hex.EncodeToString(sum[:])

View File

@@ -66,7 +66,7 @@ func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) {
t.Fatalf("catalog prompt input metadata = %#v", catalogInput) t.Fatalf("catalog prompt input metadata = %#v", catalogInput)
} }
var catalogPayload struct { var catalogPayload struct {
SpellNames []string `json:"spell_names"` Spells []spellcatalog.PromptSpell `json:"spells"`
} }
if err := json.Unmarshal(catalogInput.Content, &catalogPayload); err != nil { if err := json.Unmarshal(catalogInput.Content, &catalogPayload); err != nil {
t.Fatalf("decode catalog prompt input: %v", err) t.Fatalf("decode catalog prompt input: %v", err)
@@ -80,24 +80,42 @@ func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) {
wantNames = append(wantNames, spell.Name) wantNames = append(wantNames, spell.Name)
} }
sort.Strings(wantNames) sort.Strings(wantNames)
if !reflect.DeepEqual(catalogPayload.SpellNames, wantNames) || !sort.StringsAreSorted(catalogPayload.SpellNames) { gotNames := make([]string, len(catalogPayload.Spells))
t.Fatalf("catalog prompt names = %d entries, want sorted base catalog", len(catalogPayload.SpellNames)) 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{}}} client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
if _, err := newExtractor(t, client, overlaySpellCatalogReference()).Extract(context.Background(), extractionRequest()); err != nil { if _, err := newExtractor(t, client, overlaySpellCatalogReference()).Extract(context.Background(), extractionRequest()); err != nil {
t.Fatalf("Extract() error = %v, want nil", err) t.Fatalf("Extract() error = %v, want nil", err)
} }
input := client.requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot] input := client.requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot]
content := string(input.Content) content := string(input.Content)
for _, expected := range []string{"Aegis of Emberfall", `"spell_names"`} { var payload struct {
if !strings.Contains(content, expected) { Spells []spellcatalog.PromptSpell `json:"spells"`
t.Fatalf("catalog prompt input = %q, want %q", content, expected) }
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) { if strings.Contains(content, forbidden) {
t.Fatalf("catalog prompt input leaked %q: %s", forbidden, content) 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) { func TestNewRejectsMalformedCatalogBeforeLLMCall(t *testing.T) {
client := &fakeSpellsLLMClient{} client := &fakeSpellsLLMClient{}
_, err := New(client, Options{}, spellCatalogReference(`{"schema_version":"notarius.dnd.spell-catalog-overlay.v2","catalogs":[]}`)) _, 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", ProfileID: "spell-test-profile",
Inputs: map[string]promptkit.ArtifactRef{ Inputs: map[string]promptkit.ArtifactRef{
"transcript": promptkit.InlineWithURI("file:///session.json", string(transcript)), "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"}]}`), "npc_registry": promptkit.Inline(`{"npcs":[{"name":"spell-npc-sentinel"}]}`),
"players": promptkit.Inline(players), "players": promptkit.Inline(players),
"party": promptkit.Inline(party), "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 // comparable through SourceRefOrder's literal fallback so malformed candidates
// are still safe to sort and diagnose. // are still safe to sort and diagnose.
func Less(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool { 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) leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs)
rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs) rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs)
if leftHasEvidence != rightHasEvidence { 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 { if less, decided := optionalQuantityLess(left.Quantity, right.Quantity); decided {
return less 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 // ExactEqual reports whether occurrences are exact duplicates after their display
// fields and evidence have been canonicalized for the supplied source order. // fields and evidence have been canonicalized for the supplied source order.
func ExactEqual(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool { 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 || 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) || DisplayValue(left.From) != DisplayValue(right.From) || DisplayValue(left.To) != DisplayValue(right.To) ||
(left.Quantity == nil) != (right.Quantity == nil) { (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 { if left.Quantity != nil && *left.Quantity != *right.Quantity {
return false 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 // ExactIdentity returns a collision-safe duplicate key after display and
// evidence canonicalization. It is intended for callers that have already // evidence canonicalization. It is intended for callers that have already
// decided the occurrence is eligible for duplicate handling. // decided the occurrence is eligible for duplicate handling.
func ExactIdentity(order shared.SourceRefOrder, occurrence dnd.ItemOccurrence) string { 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 var key strings.Builder
writeKeyString(&key, occurrence.ItemID) writeKeyString(&key, occurrence.ItemID)
writeKeyString(&key, DisplayValue(occurrence.Name)) writeKeyString(&key, DisplayValue(occurrence.Name))
@@ -155,14 +179,24 @@ func ExactIdentity(order shared.SourceRefOrder, occurrence dnd.ItemOccurrence) s
key.WriteByte('1') key.WriteByte('1')
writeKeyInt(&key, *occurrence.Quantity) writeKeyInt(&key, *occurrence.Quantity)
} }
for _, ref := range order.Canonicalize(occurrence.SourceRefs) { writeKeySourceRefs(&key, occurrence.SourceRefs)
writeKeyString(&key, ref.SourceID)
writeKeyInt(&key, ref.StartUnitID)
writeKeyInt(&key, ref.EndUnitID)
}
return key.String() 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) { func optionalStringLess(left, right string) (bool, bool) {
leftPresent, rightPresent := HolderPresent(left), HolderPresent(right) leftPresent, rightPresent := HolderPresent(left), HolderPresent(right)
if leftPresent != rightPresent { if leftPresent != rightPresent {

Some files were not shown because too many files have changed in this diff Show More