Add opt-in JSON chunk map export
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
)
|
)
|
||||||
@@ -17,18 +18,25 @@ const Key = "json"
|
|||||||
|
|
||||||
const contentTypeJSON = "application/json"
|
const contentTypeJSON = "application/json"
|
||||||
|
|
||||||
|
const chunkMapFileName = "chunk-map.json"
|
||||||
|
|
||||||
var safeOutputFileChar = regexp.MustCompile(`[^A-Za-z0-9._-]`)
|
var safeOutputFileChar = regexp.MustCompile(`[^A-Za-z0-9._-]`)
|
||||||
|
|
||||||
var _ contracts.OutputEncoder = (*Encoder)(nil)
|
var _ contracts.OutputEncoder = (*Encoder)(nil)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct {
|
||||||
|
IncludeChunkMap bool
|
||||||
|
}
|
||||||
|
|
||||||
type Encoder struct {
|
type Encoder struct {
|
||||||
options Options
|
options Options
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Encoder {
|
func New() *Encoder {
|
||||||
options, _ := DecodeOptions(nil)
|
return NewWithOptions(Options{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWithOptions(options Options) *Encoder {
|
||||||
return &Encoder{options: options}
|
return &Encoder{options: options}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +55,7 @@ func (e *Encoder) Encode(ctx context.Context, req contracts.OutputRequest) (cont
|
|||||||
return contracts.OutputResult{}, encoderErrorf("context error before encoding: %w", err)
|
return contracts.OutputResult{}, encoderErrorf("context error before encoding: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := logicalFiles(req)
|
files, err := logicalFiles(req, e.options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.OutputResult{}, err
|
return contracts.OutputResult{}, err
|
||||||
}
|
}
|
||||||
@@ -79,9 +87,16 @@ func validateOptions(options map[string]any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func DecodeOptions(options map[string]any) (Options, error) {
|
func DecodeOptions(options map[string]any) (Options, error) {
|
||||||
if err := pipeline.RejectUnknownOptions(options); err != nil {
|
if err := pipeline.RejectUnknownOptions(options, "include_chunk_map"); err != nil {
|
||||||
return Options{}, encoderErrorf("%w", err)
|
return Options{}, encoderErrorf("%w", err)
|
||||||
}
|
}
|
||||||
|
if value, ok := options["include_chunk_map"]; ok {
|
||||||
|
enabled, ok := value.(bool)
|
||||||
|
if !ok {
|
||||||
|
return Options{}, encoderErrorf("option %q must be a boolean", "include_chunk_map")
|
||||||
|
}
|
||||||
|
return Options{IncludeChunkMap: enabled}, nil
|
||||||
|
}
|
||||||
return Options{}, nil
|
return Options{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +105,16 @@ type indexFile struct {
|
|||||||
OutputFiles []outputFileIndex `json:"output_files"`
|
OutputFiles []outputFileIndex `json:"output_files"`
|
||||||
RejectedFile string `json:"rejected_file"`
|
RejectedFile string `json:"rejected_file"`
|
||||||
WarningsFile string `json:"warnings_file"`
|
WarningsFile string `json:"warnings_file"`
|
||||||
|
ChunkMap *chunkMapIndex `json:"chunk_map,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chunkMapIndex struct {
|
||||||
|
ArtifactKind contracts.ArtifactKind `json:"artifact_kind"`
|
||||||
|
File string `json:"file"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
SchemaID string `json:"schema_id"`
|
||||||
|
SchemaName string `json:"schema_name"`
|
||||||
|
SchemaVersion string `json:"schema_version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type outputFileIndex struct {
|
type outputFileIndex struct {
|
||||||
@@ -110,7 +135,7 @@ type warningsFile struct {
|
|||||||
Warnings []contracts.Warning `json:"warnings"`
|
Warnings []contracts.Warning `json:"warnings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func logicalFiles(req contracts.OutputRequest) ([]contracts.OutputFile, error) {
|
func logicalFiles(req contracts.OutputRequest, options Options) ([]contracts.OutputFile, error) {
|
||||||
outputs := cloneNormalizeOutputs(req.NormalizeOutputs)
|
outputs := cloneNormalizeOutputs(req.NormalizeOutputs)
|
||||||
sort.SliceStable(outputs, func(i, j int) bool {
|
sort.SliceStable(outputs, func(i, j int) bool {
|
||||||
return outputs[i].LaneID < outputs[j].LaneID
|
return outputs[i].LaneID < outputs[j].LaneID
|
||||||
@@ -156,6 +181,14 @@ func logicalFiles(req contracts.OutputRequest) ([]contracts.OutputFile, error) {
|
|||||||
RejectedFile: "rejected.json",
|
RejectedFile: "rejected.json",
|
||||||
WarningsFile: "warnings.json",
|
WarningsFile: "warnings.json",
|
||||||
}
|
}
|
||||||
|
if options.IncludeChunkMap && req.ChunkMap != nil {
|
||||||
|
chunkMapOutput, chunkMapDescriptor, err := serializedChunkMapFile(*req.ChunkMap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
files = append(files, chunkMapOutput)
|
||||||
|
index.ChunkMap = &chunkMapDescriptor
|
||||||
|
}
|
||||||
indexOutput, err := jsonFile("index.json", index)
|
indexOutput, err := jsonFile("index.json", index)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -175,6 +208,33 @@ func logicalFiles(req contracts.OutputRequest) ([]contracts.OutputFile, error) {
|
|||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serializedChunkMapFile(artifact contracts.SerializedArtifact) (contracts.OutputFile, chunkMapIndex, error) {
|
||||||
|
if artifact.Kind != chunkmap.ArtifactKind {
|
||||||
|
return contracts.OutputFile{}, chunkMapIndex{}, encoderErrorf("chunk map has unexpected artifact kind %q", artifact.Kind)
|
||||||
|
}
|
||||||
|
if artifact.Schema.ID != chunkmap.SchemaID || artifact.Schema.Name != chunkmap.SchemaName || artifact.Schema.Version != chunkmap.SchemaVersion {
|
||||||
|
return contracts.OutputFile{}, chunkMapIndex{}, encoderErrorf("chunk map has unexpected schema identity")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(artifact.MediaType) != chunkmap.MediaType {
|
||||||
|
return contracts.OutputFile{}, chunkMapIndex{}, encoderErrorf("chunk map has unsupported media type %q", artifact.MediaType)
|
||||||
|
}
|
||||||
|
if _, err := chunkmap.New().Decode(artifact.Content); err != nil {
|
||||||
|
return contracts.OutputFile{}, chunkMapIndex{}, encoderErrorf("decode chunk map: %w", err)
|
||||||
|
}
|
||||||
|
file, err := serializedOutputFile(chunkMapFileName, artifact)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.OutputFile{}, chunkMapIndex{}, err
|
||||||
|
}
|
||||||
|
return file, chunkMapIndex{
|
||||||
|
ArtifactKind: artifact.Kind,
|
||||||
|
File: chunkMapFileName,
|
||||||
|
MediaType: chunkmap.MediaType,
|
||||||
|
SchemaID: artifact.Schema.ID,
|
||||||
|
SchemaName: artifact.Schema.Name,
|
||||||
|
SchemaVersion: artifact.Schema.Version,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func serializedOutputFile(name string, artifact contracts.SerializedArtifact) (contracts.OutputFile, error) {
|
func serializedOutputFile(name string, artifact contracts.SerializedArtifact) (contracts.OutputFile, error) {
|
||||||
content := append([]byte(nil), artifact.Content...)
|
content := append([]byte(nil), artifact.Content...)
|
||||||
if len(content) == 0 {
|
if len(content) == 0 {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
)
|
)
|
||||||
@@ -37,6 +39,40 @@ func TestModuleSpecAndRegister(t *testing.T) {
|
|||||||
if err := registry.ValidateOptions(Key, map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
if err := registry.ValidateOptions(Key, map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
||||||
t.Fatalf("ValidateOptions() error = %v, want unknown option error", err)
|
t.Fatalf("ValidateOptions() error = %v, want unknown option error", err)
|
||||||
}
|
}
|
||||||
|
if err := registry.ValidateOptions(Key, map[string]any{"include_chunk_map": true}); err != nil {
|
||||||
|
t.Fatalf("ValidateOptions() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeOptions(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
options map[string]any
|
||||||
|
want Options
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "omitted", want: Options{}},
|
||||||
|
{name: "disabled", options: map[string]any{"include_chunk_map": false}, want: Options{}},
|
||||||
|
{name: "enabled", options: map[string]any{"include_chunk_map": true}, want: Options{IncludeChunkMap: true}},
|
||||||
|
{name: "wrong type", options: map[string]any{"include_chunk_map": "true"}, wantErr: "must be a boolean"},
|
||||||
|
{name: "unknown", options: map[string]any{"unknown": true}, wantErr: "unknown option"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, err := DecodeOptions(test.options)
|
||||||
|
if test.wantErr != "" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||||
|
t.Fatalf("DecodeOptions() error = %v, want %q", err, test.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeOptions() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if got != test.want {
|
||||||
|
t.Fatalf("DecodeOptions() = %#v, want %#v", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEncodeReturnsLogicalFilesForNormalizedOutputs(t *testing.T) {
|
func TestEncodeReturnsLogicalFilesForNormalizedOutputs(t *testing.T) {
|
||||||
@@ -129,6 +165,133 @@ func TestEncodeIncludesRejectedAndWarningsWhenEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEncodeChunkMapExportIsOptIn(t *testing.T) {
|
||||||
|
artifact := acceptedChunkMapArtifact(t)
|
||||||
|
request := contracts.OutputRequest{
|
||||||
|
Manifest: artifacts.RunManifest{RunID: "run-1"},
|
||||||
|
ChunkMap: &artifact,
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
encoder *Encoder
|
||||||
|
}{
|
||||||
|
{name: "default", encoder: New()},
|
||||||
|
{name: "explicitly disabled", encoder: NewWithOptions(Options{IncludeChunkMap: false})},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := test.encoder.Encode(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if got, want := outputFileNames(result.Files), []string{"index.json", "manifest.json", "rejected.json", "warnings.json"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("file names = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
|
||||||
|
if _, ok := index["chunk_map"]; ok {
|
||||||
|
t.Fatalf("index = %#v, want no chunk map descriptor", index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeIncludesValidatedChunkMap(t *testing.T) {
|
||||||
|
artifact := acceptedChunkMapArtifact(t)
|
||||||
|
result, err := NewWithOptions(Options{IncludeChunkMap: true}).Encode(context.Background(), contracts.OutputRequest{
|
||||||
|
Manifest: artifacts.RunManifest{RunID: "run-1"},
|
||||||
|
NormalizeOutputs: []contracts.SerializedOutput{
|
||||||
|
normalizeOutput("spells", `{"spell_casts":[]}`),
|
||||||
|
},
|
||||||
|
ChunkMap: &artifact,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if got, want := outputFileNames(result.Files), []string{"chunk-map.json", "index.json", "lanes/spells.json", "manifest.json", "rejected.json", "warnings.json"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("file names = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
chunkMapFile := fileBytes(t, result.Files, chunkMapFileName)
|
||||||
|
if !stdjson.Valid(chunkMapFile) || !strings.HasSuffix(string(chunkMapFile), "\n") || !strings.Contains(string(chunkMapFile), "\n \"source_id\"") {
|
||||||
|
t.Fatalf("chunk map file = %q, want pretty valid newline-terminated JSON", chunkMapFile)
|
||||||
|
}
|
||||||
|
if _, err := chunkmap.New().Decode(chunkMapFile); err != nil {
|
||||||
|
t.Fatalf("Decode(chunk map file) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
|
||||||
|
if got, want := index["chunk_map"], map[string]any{
|
||||||
|
"artifact_kind": "source/chunk-map",
|
||||||
|
"file": chunkMapFileName,
|
||||||
|
"media_type": chunkmap.MediaType,
|
||||||
|
"schema_id": chunkmap.SchemaID,
|
||||||
|
"schema_name": chunkmap.SchemaName,
|
||||||
|
"schema_version": chunkmap.SchemaVersion,
|
||||||
|
}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("chunk map descriptor = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
for _, entry := range index["output_files"].([]any) {
|
||||||
|
if entry.(map[string]any)["file"] == chunkMapFileName {
|
||||||
|
t.Fatalf("output_files = %#v, want no chunk map", index["output_files"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeOmitsChunkMapWithoutAcceptedArtifact(t *testing.T) {
|
||||||
|
result, err := NewWithOptions(Options{IncludeChunkMap: true}).Encode(context.Background(), contracts.OutputRequest{
|
||||||
|
Manifest: artifacts.RunManifest{RunID: "run-1"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if got := outputFileNames(result.Files); containsString(got, chunkMapFileName) {
|
||||||
|
t.Fatalf("file names = %#v, want no chunk map", got)
|
||||||
|
}
|
||||||
|
index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
|
||||||
|
if _, ok := index["chunk_map"]; ok {
|
||||||
|
t.Fatalf("index = %#v, want no chunk map descriptor", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeRejectsInvalidChunkMapArtifact(t *testing.T) {
|
||||||
|
artifact := acceptedChunkMapArtifact(t)
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*contracts.SerializedArtifact)
|
||||||
|
}{
|
||||||
|
{name: "kind", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Kind = "other/chunk-map" }},
|
||||||
|
{name: "schema", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Schema.Version = "v2" }},
|
||||||
|
{name: "media type", mutate: func(artifact *contracts.SerializedArtifact) { artifact.MediaType = "text/plain" }},
|
||||||
|
{name: "content", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Content = []byte(`{}`) }},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
candidate := contracts.CloneSerializedArtifact(artifact)
|
||||||
|
test.mutate(&candidate)
|
||||||
|
_, err := NewWithOptions(Options{IncludeChunkMap: true}).Encode(context.Background(), contracts.OutputRequest{ChunkMap: &candidate})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Encode() error = nil, want invalid chunk map error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeChunkMapDoesNotMutateRequest(t *testing.T) {
|
||||||
|
artifact := acceptedChunkMapArtifact(t)
|
||||||
|
artifact.Metadata = map[string]any{"owner": "caller"}
|
||||||
|
request := contracts.OutputRequest{ChunkMap: &artifact}
|
||||||
|
before := contracts.CloneSerializedArtifact(artifact)
|
||||||
|
result, err := NewWithOptions(Options{IncludeChunkMap: true}).Encode(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(*request.ChunkMap, before) {
|
||||||
|
t.Fatalf("chunk map artifact mutated:\nbefore: %#v\nafter: %#v", before, *request.ChunkMap)
|
||||||
|
}
|
||||||
|
request.ChunkMap.Content[0] = '['
|
||||||
|
request.ChunkMap.Schema.JSONSchema[0] = '['
|
||||||
|
request.ChunkMap.Metadata["owner"] = "changed"
|
||||||
|
if _, err := chunkmap.New().Decode(fileBytes(t, result.Files, chunkMapFileName)); err != nil {
|
||||||
|
t.Fatalf("chunk map output changed after request mutation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEncodePrettyPrintsJSON(t *testing.T) {
|
func TestEncodePrettyPrintsJSON(t *testing.T) {
|
||||||
result, err := New().Encode(context.Background(), contracts.OutputRequest{
|
result, err := New().Encode(context.Background(), contracts.OutputRequest{
|
||||||
Manifest: artifacts.RunManifest{RunID: "run-1"},
|
Manifest: artifacts.RunManifest{RunID: "run-1"},
|
||||||
@@ -380,6 +543,39 @@ func normalizeOutput(laneID string, content string) contracts.SerializedOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func acceptedChunkMapArtifact(t *testing.T) contracts.SerializedArtifact {
|
||||||
|
t.Helper()
|
||||||
|
document := &source.SourceDocument{
|
||||||
|
ID: "source-1",
|
||||||
|
Kind: "text",
|
||||||
|
Format: "text/plain",
|
||||||
|
Units: []source.SourceUnit{{
|
||||||
|
ID: 1,
|
||||||
|
Kind: "text",
|
||||||
|
Text: "Accepted source content.",
|
||||||
|
Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
digest, err := source.DigestDocument(document)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
document.Digest = digest
|
||||||
|
plan := source.ChunkPlan{SourceDigest: digest, Ranges: []source.ChunkRange{{StartUnitID: 1, EndUnitID: 1}}}
|
||||||
|
chunks, err := source.MaterializeChunkPlan(document, plan)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
artifact, err := chunkmap.Serialize(chunkmap.BuildRequest{
|
||||||
|
Source: document, Plan: plan, Chunks: chunks, RequestedChunker: "generic/units",
|
||||||
|
Producer: chunkmap.Producer{InputModule: "test/input", ChunkModule: "generic/units"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return artifact
|
||||||
|
}
|
||||||
|
|
||||||
func outputFileNames(files []contracts.OutputFile) []string {
|
func outputFileNames(files []contracts.OutputFile) []string {
|
||||||
names := make([]string, 0, len(files))
|
names := make([]string, 0, len(files))
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
|
|||||||
Reference in New Issue
Block a user