Add accepted chunk map contract
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "notarius.source.chunk_map",
|
||||
"title": "notarius_source_chunk_map_v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"source_id",
|
||||
"source_digest",
|
||||
"plan_digest",
|
||||
"requested_chunker",
|
||||
"producer",
|
||||
"plan_annotations",
|
||||
"chunks"
|
||||
],
|
||||
"properties": {
|
||||
"source_id": {"type": "string", "minLength": 1},
|
||||
"source_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
|
||||
"plan_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
|
||||
"requested_chunker": {"type": "string", "minLength": 1},
|
||||
"producer": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["input_module", "chunk_module"],
|
||||
"properties": {
|
||||
"input_module": {"type": "string", "minLength": 1},
|
||||
"chunk_module": {"type": "string", "minLength": 1},
|
||||
"llm_profile": {"type": "string", "minLength": 1}
|
||||
}
|
||||
},
|
||||
"plan_annotations": {"$ref": "#/$defs/annotations"},
|
||||
"chunks": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "index", "source_ref", "unit_count", "annotations"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"index": {"type": "integer", "minimum": 0},
|
||||
"source_ref": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["source_id", "start_unit_id", "end_unit_id"],
|
||||
"properties": {
|
||||
"source_id": {"type": "string", "minLength": 1},
|
||||
"start_unit_id": {"type": "integer", "minimum": 1},
|
||||
"end_unit_id": {"type": "integer", "minimum": 1}
|
||||
}
|
||||
},
|
||||
"unit_count": {"type": "integer", "minimum": 1},
|
||||
"annotations": {"$ref": "#/$defs/annotations"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"annotations": {
|
||||
"type": "object",
|
||||
"propertyNames": {"type": "string", "minLength": 1},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
347
internal/framework/chunkmap/codec.go
Normal file
347
internal/framework/chunkmap/codec.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package chunkmap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
//go:embed assets/schemas/source_chunk_map.v1.json
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
|
||||
// Codec owns strict serialization for the durable chunk-map contract.
|
||||
type Codec struct{}
|
||||
|
||||
func New() *Codec { return &Codec{} }
|
||||
|
||||
func (c *Codec) Kind() contracts.ArtifactKind { return ArtifactKind }
|
||||
|
||||
func (c *Codec) Schema() contracts.ArtifactSchema {
|
||||
raw, err := c.schemaBytes()
|
||||
if err != nil {
|
||||
return contracts.ArtifactSchema{}
|
||||
}
|
||||
return contracts.ArtifactSchema{
|
||||
ID: SchemaID,
|
||||
Name: SchemaName,
|
||||
Version: SchemaVersion,
|
||||
JSONSchema: raw,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Codec) MediaType() string { return MediaType }
|
||||
|
||||
// Build proves that a durable value describes the exact accepted source plan
|
||||
// and materialized chunk list supplied by the framework.
|
||||
func Build(request BuildRequest) (ChunkMap, error) {
|
||||
if err := source.ValidateDocument(request.Source); err != nil {
|
||||
return ChunkMap{}, fmt.Errorf("validate source document: %w", err)
|
||||
}
|
||||
sourceDigest, err := source.DigestDocument(request.Source)
|
||||
if err != nil {
|
||||
return ChunkMap{}, fmt.Errorf("digest source document: %w", err)
|
||||
}
|
||||
if sourceDigest != request.Source.Digest {
|
||||
return ChunkMap{}, fmt.Errorf("source digest %q does not match source document digest %q", sourceDigest, request.Source.Digest)
|
||||
}
|
||||
if sourceDigest != request.Plan.SourceDigest {
|
||||
return ChunkMap{}, fmt.Errorf("source digest %q does not match chunk plan source digest %q", sourceDigest, request.Plan.SourceDigest)
|
||||
}
|
||||
plan, err := source.CanonicalizeChunkPlan(request.Plan)
|
||||
if err != nil {
|
||||
return ChunkMap{}, fmt.Errorf("canonicalize chunk plan: %w", err)
|
||||
}
|
||||
if err := source.ValidateChunkPlan(request.Source, plan); err != nil {
|
||||
return ChunkMap{}, fmt.Errorf("validate accepted chunk plan: %w", err)
|
||||
}
|
||||
planDigest, err := source.DigestChunkPlan(plan)
|
||||
if err != nil {
|
||||
return ChunkMap{}, fmt.Errorf("digest accepted chunk plan: %w", err)
|
||||
}
|
||||
expected, err := source.MaterializeChunkPlan(request.Source, plan)
|
||||
if err != nil {
|
||||
return ChunkMap{}, fmt.Errorf("materialize accepted chunk plan: %w", err)
|
||||
}
|
||||
if err := verifyMaterializedChunks(request.Chunks, expected); err != nil {
|
||||
return ChunkMap{}, err
|
||||
}
|
||||
|
||||
value := ChunkMap{
|
||||
SourceID: request.Source.ID,
|
||||
SourceDigest: sourceDigest,
|
||||
PlanDigest: planDigest,
|
||||
RequestedChunker: request.RequestedChunker,
|
||||
Producer: request.Producer,
|
||||
PlanAnnotations: source.CloneChunkAnnotations(plan.Annotations),
|
||||
Chunks: make([]Chunk, len(expected)),
|
||||
}
|
||||
for index, chunk := range expected {
|
||||
value.Chunks[index] = Chunk{
|
||||
ID: chunk.ID,
|
||||
Index: chunk.Index,
|
||||
SourceRef: chunk.Ref,
|
||||
UnitCount: len(chunk.Units),
|
||||
Annotations: source.CloneChunkAnnotations(chunk.Annotations),
|
||||
}
|
||||
}
|
||||
canonical, err := canonicalize(value)
|
||||
if err != nil {
|
||||
return ChunkMap{}, fmt.Errorf("validate chunk map: %w", err)
|
||||
}
|
||||
return clone(canonical), nil
|
||||
}
|
||||
|
||||
// Serialize builds and encodes the framework-owned serialized artifact.
|
||||
func Serialize(request BuildRequest) (contracts.SerializedArtifact, error) {
|
||||
value, err := Build(request)
|
||||
if err != nil {
|
||||
return contracts.SerializedArtifact{}, err
|
||||
}
|
||||
codec := New()
|
||||
content, err := codec.Encode(value)
|
||||
if err != nil {
|
||||
return contracts.SerializedArtifact{}, err
|
||||
}
|
||||
return contracts.SerializedArtifact{
|
||||
Kind: ArtifactKind,
|
||||
Schema: codec.Schema(),
|
||||
MediaType: MediaType,
|
||||
Content: content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Codec) Encode(value ChunkMap) ([]byte, error) {
|
||||
if _, err := c.schemaBytes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonical, err := canonicalize(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode source chunk map: %w", err)
|
||||
}
|
||||
content, err := json.Marshal(canonical)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode source chunk map: %w", err)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (c *Codec) Decode(content []byte) (ChunkMap, error) {
|
||||
if _, err := c.schemaBytes(); err != nil {
|
||||
return ChunkMap{}, err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||
decoder.DisallowUnknownFields()
|
||||
var value ChunkMap
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return ChunkMap{}, fmt.Errorf("decode source chunk map: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
return ChunkMap{}, fmt.Errorf("decode source chunk map: multiple JSON values")
|
||||
}
|
||||
canonical, err := canonicalize(value)
|
||||
if err != nil {
|
||||
return ChunkMap{}, fmt.Errorf("decode source chunk map: %w", err)
|
||||
}
|
||||
return clone(canonical), nil
|
||||
}
|
||||
|
||||
func (c *Codec) schemaBytes() ([]byte, error) {
|
||||
raw, err := schemaAssets.ReadFile("assets/schemas/source_chunk_map.v1.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read source chunk map schema: %w", err)
|
||||
}
|
||||
var schema struct {
|
||||
ID string `json:"$id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
return nil, fmt.Errorf("decode source chunk map schema: %w", err)
|
||||
}
|
||||
if schema.ID != SchemaID || schema.Title != SchemaName || schema.Type != "object" || !hasRequiredFields(schema.Required) {
|
||||
return nil, fmt.Errorf("source chunk map schema identity or required fields are invalid")
|
||||
}
|
||||
return append([]byte(nil), raw...), nil
|
||||
}
|
||||
|
||||
func hasRequiredFields(required []string) bool {
|
||||
want := map[string]bool{
|
||||
"source_id": true, "source_digest": true, "plan_digest": true,
|
||||
"requested_chunker": true, "producer": true, "plan_annotations": true,
|
||||
"chunks": true,
|
||||
}
|
||||
for _, field := range required {
|
||||
delete(want, field)
|
||||
}
|
||||
return len(want) == 0
|
||||
}
|
||||
|
||||
func canonicalize(value ChunkMap) (ChunkMap, error) {
|
||||
if err := requireIdentity("source_id", value.SourceID); err != nil {
|
||||
return ChunkMap{}, err
|
||||
}
|
||||
if err := requireDigest("source_digest", value.SourceDigest); err != nil {
|
||||
return ChunkMap{}, err
|
||||
}
|
||||
if err := requireDigest("plan_digest", value.PlanDigest); err != nil {
|
||||
return ChunkMap{}, err
|
||||
}
|
||||
if err := requireIdentity("requested_chunker", value.RequestedChunker); err != nil {
|
||||
return ChunkMap{}, err
|
||||
}
|
||||
if err := requireIdentity("producer.input_module", value.Producer.InputModule); err != nil {
|
||||
return ChunkMap{}, err
|
||||
}
|
||||
if err := requireIdentity("producer.chunk_module", value.Producer.ChunkModule); err != nil {
|
||||
return ChunkMap{}, err
|
||||
}
|
||||
if value.Producer.LLMProfile != "" {
|
||||
if err := requireIdentity("producer.llm_profile", value.Producer.LLMProfile); err != nil {
|
||||
return ChunkMap{}, err
|
||||
}
|
||||
}
|
||||
annotations, err := canonicalizeAnnotations("plan_annotations", value.PlanAnnotations)
|
||||
if err != nil {
|
||||
return ChunkMap{}, err
|
||||
}
|
||||
value.PlanAnnotations = annotations
|
||||
if len(value.Chunks) == 0 {
|
||||
return ChunkMap{}, fmt.Errorf("chunks must not be empty")
|
||||
}
|
||||
seenIDs := make(map[string]struct{}, len(value.Chunks))
|
||||
plan := source.ChunkPlan{SourceDigest: value.SourceDigest, Annotations: annotations, Ranges: make([]source.ChunkRange, len(value.Chunks))}
|
||||
for index := range value.Chunks {
|
||||
chunk := &value.Chunks[index]
|
||||
if err := requireIdentity(fmt.Sprintf("chunks[%d].id", index), chunk.ID); err != nil {
|
||||
return ChunkMap{}, err
|
||||
}
|
||||
if _, exists := seenIDs[chunk.ID]; exists {
|
||||
return ChunkMap{}, fmt.Errorf("chunks[%d].id %q is duplicated", index, chunk.ID)
|
||||
}
|
||||
seenIDs[chunk.ID] = struct{}{}
|
||||
if chunk.Index != index {
|
||||
return ChunkMap{}, fmt.Errorf("chunks[%d].index = %d, want %d", index, chunk.Index, index)
|
||||
}
|
||||
if chunk.SourceRef.SourceID != value.SourceID {
|
||||
return ChunkMap{}, fmt.Errorf("chunks[%d].source_ref.source_id %q does not match source_id %q", index, chunk.SourceRef.SourceID, value.SourceID)
|
||||
}
|
||||
if chunk.SourceRef.StartUnitID <= 0 || chunk.SourceRef.EndUnitID <= 0 {
|
||||
return ChunkMap{}, fmt.Errorf("chunks[%d].source_ref endpoints must be positive", index)
|
||||
}
|
||||
if chunk.UnitCount <= 0 {
|
||||
return ChunkMap{}, fmt.Errorf("chunks[%d].unit_count must be positive", index)
|
||||
}
|
||||
chunkAnnotations, err := canonicalizeAnnotations(fmt.Sprintf("chunks[%d].annotations", index), chunk.Annotations)
|
||||
if err != nil {
|
||||
return ChunkMap{}, err
|
||||
}
|
||||
chunk.Annotations = chunkAnnotations
|
||||
plan.Ranges[index] = source.ChunkRange{
|
||||
StartUnitID: chunk.SourceRef.StartUnitID,
|
||||
EndUnitID: chunk.SourceRef.EndUnitID,
|
||||
Annotations: chunkAnnotations,
|
||||
}
|
||||
}
|
||||
planDigest, err := source.DigestChunkPlan(plan)
|
||||
if err != nil {
|
||||
return ChunkMap{}, fmt.Errorf("reconstruct plan digest: %w", err)
|
||||
}
|
||||
if planDigest != value.PlanDigest {
|
||||
return ChunkMap{}, fmt.Errorf("plan_digest %q does not match reconstructed plan digest %q", value.PlanDigest, planDigest)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func canonicalizeAnnotations(name string, annotations source.ChunkAnnotations) (source.ChunkAnnotations, error) {
|
||||
for namespace := range annotations {
|
||||
if strings.TrimSpace(namespace) == "" || namespace != strings.TrimSpace(namespace) {
|
||||
return nil, fmt.Errorf("%s namespace %q must be non-empty and trimmed", name, namespace)
|
||||
}
|
||||
}
|
||||
canonical, err := source.CanonicalizeChunkAnnotations(annotations)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
if canonical == nil {
|
||||
canonical = source.ChunkAnnotations{}
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func requireIdentity(name, value string) error {
|
||||
if strings.TrimSpace(value) == "" || value != strings.TrimSpace(value) {
|
||||
return fmt.Errorf("%s must be non-empty and trimmed", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireDigest(name, value string) error {
|
||||
if !digestPattern.MatchString(value) {
|
||||
return fmt.Errorf("%s must be a canonical sha256 digest", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyMaterializedChunks(actual, expected []source.Chunk) error {
|
||||
if len(actual) != len(expected) {
|
||||
return fmt.Errorf("materialized chunks length = %d, want %d", len(actual), len(expected))
|
||||
}
|
||||
for index := range expected {
|
||||
got, want := actual[index], expected[index]
|
||||
if got.ID != want.ID || got.SourceID != want.SourceID || got.Index != want.Index || got.Ref != want.Ref {
|
||||
return fmt.Errorf("materialized chunk[%d] identity or source range differs from accepted plan", index)
|
||||
}
|
||||
if len(got.Units) != len(want.Units) || !sameUnits(got.Units, want.Units) {
|
||||
return fmt.Errorf("materialized chunk[%d] units differ from accepted source range", index)
|
||||
}
|
||||
if !sameAnnotations(got.PlanAnnotations, want.PlanAnnotations) || !sameAnnotations(got.Annotations, want.Annotations) {
|
||||
return fmt.Errorf("materialized chunk[%d] annotations differ from accepted plan", index)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameUnits(left, right []source.SourceUnit) bool {
|
||||
leftJSON, leftErr := json.Marshal(left)
|
||||
rightJSON, rightErr := json.Marshal(right)
|
||||
return leftErr == nil && rightErr == nil && bytes.Equal(leftJSON, rightJSON)
|
||||
}
|
||||
|
||||
func sameAnnotations(left, right source.ChunkAnnotations) bool {
|
||||
leftCanonical, leftErr := source.CanonicalizeChunkAnnotations(left)
|
||||
rightCanonical, rightErr := source.CanonicalizeChunkAnnotations(right)
|
||||
if leftErr != nil || rightErr != nil {
|
||||
return false
|
||||
}
|
||||
leftJSON, leftErr := json.Marshal(leftCanonical)
|
||||
rightJSON, rightErr := json.Marshal(rightCanonical)
|
||||
return leftErr == nil && rightErr == nil && bytes.Equal(leftJSON, rightJSON)
|
||||
}
|
||||
|
||||
func clone(value ChunkMap) ChunkMap {
|
||||
value.PlanAnnotations = cloneAnnotations(value.PlanAnnotations)
|
||||
value.Chunks = append([]Chunk(nil), value.Chunks...)
|
||||
for index := range value.Chunks {
|
||||
value.Chunks[index].Annotations = cloneAnnotations(value.Chunks[index].Annotations)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func cloneAnnotations(annotations source.ChunkAnnotations) source.ChunkAnnotations {
|
||||
cloned := source.CloneChunkAnnotations(annotations)
|
||||
if cloned == nil {
|
||||
return source.ChunkAnnotations{}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
191
internal/framework/chunkmap/codec_test.go
Normal file
191
internal/framework/chunkmap/codec_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package chunkmap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
func TestBuildAndSerializeAcceptedChunkMap(t *testing.T) {
|
||||
request := acceptedBuildRequest(t)
|
||||
value, err := Build(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
if value.SourceID != request.Source.ID || len(value.Chunks) != 2 || value.Chunks[0].UnitCount != 2 || value.Chunks[1].SourceRef.StartUnitID != 20 {
|
||||
t.Fatalf("Build() = %#v, want exact accepted chunk structure", value)
|
||||
}
|
||||
if value.PlanAnnotations == nil || value.Chunks[1].Annotations == nil {
|
||||
t.Fatalf("Build() annotations = %#v, want explicit maps", value)
|
||||
}
|
||||
artifact, err := Serialize(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Serialize() error = %v", err)
|
||||
}
|
||||
if artifact.Kind != ArtifactKind || artifact.Schema.ID != SchemaID || artifact.Schema.Name != SchemaName || artifact.Schema.Version != SchemaVersion || artifact.MediaType != MediaType || artifact.Metadata != nil {
|
||||
t.Fatalf("Serialize() = %#v, want fixed artifact envelope without metadata", artifact)
|
||||
}
|
||||
decoded, err := New().Decode(artifact.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(Serialize()) error = %v", err)
|
||||
}
|
||||
if decoded.PlanDigest != value.PlanDigest || decoded.Chunks[0].ID != "chunk-000001" || decoded.Chunks[1].UnitCount != 1 {
|
||||
t.Fatalf("Decode(Serialize()) = %#v, want durable chunk map", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRoundTripsValidFixture(t *testing.T) {
|
||||
fixture, err := os.ReadFile("testdata/source_chunk_map.v1.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
codec := New()
|
||||
value, err := codec.Decode(fixture)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(fixture) error = %v", err)
|
||||
}
|
||||
encoded, err := codec.Encode(value)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode(decoded fixture) error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(encoded, bytes.TrimSpace(fixture)) {
|
||||
t.Fatalf("fixture does not use canonical encoding\nwant: %s\n got: %s", fixture, encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCanonicalizesAnnotationFormatting(t *testing.T) {
|
||||
first := acceptedBuildRequest(t)
|
||||
second := acceptedBuildRequest(t)
|
||||
second.Plan.Annotations["dnd/scenes"] = json.RawMessage(" { \n \t\"title\" : \"Gate\" \n } ")
|
||||
canonical, err := source.CanonicalizeChunkPlan(second.Plan)
|
||||
if err != nil {
|
||||
t.Fatalf("CanonicalizeChunkPlan() error = %v", err)
|
||||
}
|
||||
second.Chunks, err = source.MaterializeChunkPlan(second.Source, canonical)
|
||||
if err != nil {
|
||||
t.Fatalf("MaterializeChunkPlan() error = %v", err)
|
||||
}
|
||||
firstArtifact, err := Serialize(first)
|
||||
if err != nil {
|
||||
t.Fatalf("Serialize(first) error = %v", err)
|
||||
}
|
||||
secondArtifact, err := Serialize(second)
|
||||
if err != nil {
|
||||
t.Fatalf("Serialize(second) error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(firstArtifact.Content, secondArtifact.Content) {
|
||||
t.Fatalf("serialized content differs only because annotation whitespace changed\nfirst: %s\nsecond: %s", firstArtifact.Content, secondArtifact.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRejectsChunksOutsideAcceptedPlan(t *testing.T) {
|
||||
request := acceptedBuildRequest(t)
|
||||
request.Chunks[0].Units[0].ID = 999
|
||||
if _, err := Build(request); err == nil {
|
||||
t.Fatal("Build() error = nil, want rejection for chunk units outside accepted source range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsInvalidDurableBoundaries(t *testing.T) {
|
||||
value, err := Build(acceptedBuildRequest(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*ChunkMap)
|
||||
}{
|
||||
{name: "blank identity", mutate: func(value *ChunkMap) { value.RequestedChunker = " " }},
|
||||
{name: "malformed digest", mutate: func(value *ChunkMap) { value.SourceDigest = "sha256:ABC" }},
|
||||
{name: "index mismatch", mutate: func(value *ChunkMap) { value.Chunks[1].Index = 4 }},
|
||||
{name: "duplicate chunk id", mutate: func(value *ChunkMap) { value.Chunks[1].ID = value.Chunks[0].ID }},
|
||||
{name: "source mismatch", mutate: func(value *ChunkMap) { value.Chunks[0].SourceRef.SourceID = "other" }},
|
||||
{name: "invalid range", mutate: func(value *ChunkMap) { value.Chunks[0].SourceRef.StartUnitID = 0 }},
|
||||
{name: "invalid count", mutate: func(value *ChunkMap) { value.Chunks[0].UnitCount = 0 }},
|
||||
{name: "invalid namespace", mutate: func(value *ChunkMap) { value.PlanAnnotations[" "] = json.RawMessage(`null`) }},
|
||||
{name: "invalid annotation", mutate: func(value *ChunkMap) { value.Chunks[0].Annotations["dnd/scenes"] = json.RawMessage(`{`) }},
|
||||
{name: "plan digest mismatch", mutate: func(value *ChunkMap) { value.PlanDigest = "sha256:" + strings.Repeat("a", 64) }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := clone(value)
|
||||
test.mutate(&candidate)
|
||||
if _, err := New().Encode(candidate); err == nil {
|
||||
t.Fatal("Encode() error = nil, want invalid durable value rejection")
|
||||
}
|
||||
})
|
||||
}
|
||||
content, err := New().Encode(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, raw := range [][]byte{
|
||||
append(append([]byte(nil), content[:len(content)-1]...), []byte(`,"unknown":true}`)...),
|
||||
append(append([]byte(nil), content...), []byte(` {}`)...),
|
||||
} {
|
||||
if _, err := New().Decode(raw); err == nil {
|
||||
t.Fatalf("Decode(%s) error = nil, want strict JSON rejection", raw)
|
||||
}
|
||||
}
|
||||
formatted := bytes.Replace(content, []byte(`{"title":"Gate"}`), []byte("{\n \"title\": \"Gate\"\n}"), 1)
|
||||
decoded, err := New().Decode(formatted)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(formatted annotations) error = %v", err)
|
||||
}
|
||||
if string(decoded.PlanAnnotations["dnd/scenes"]) != `{"title":"Gate"}` {
|
||||
t.Fatalf("decoded annotation = %s, want canonical JSON", decoded.PlanAnnotations["dnd/scenes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkMapOwnershipIsIndependent(t *testing.T) {
|
||||
request := acceptedBuildRequest(t)
|
||||
first, err := Build(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Plan.Annotations["dnd/scenes"][0] = '['
|
||||
first.PlanAnnotations["dnd/scenes"][0] = '['
|
||||
second, err := Build(acceptedBuildRequest(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(second.PlanAnnotations["dnd/scenes"]) != `{"title":"Gate"}` {
|
||||
t.Fatalf("Build() shared mutable annotations: %s", second.PlanAnnotations["dnd/scenes"])
|
||||
}
|
||||
}
|
||||
|
||||
func acceptedBuildRequest(t *testing.T) BuildRequest {
|
||||
t.Helper()
|
||||
document := &source.SourceDocument{
|
||||
ID: "session-7", Kind: "transcript", Format: "application/json",
|
||||
Units: []source.SourceUnit{
|
||||
{ID: 10, Kind: "segment", Text: "At the gate.", Ref: source.SourceRef{SourceID: "session-7", StartUnitID: 10, EndUnitID: 10}},
|
||||
{ID: 3, Kind: "segment", Text: "The guard speaks.", Ref: source.SourceRef{SourceID: "session-7", StartUnitID: 3, EndUnitID: 3}},
|
||||
{ID: 20, Kind: "segment", Text: "The party enters.", Ref: source.SourceRef{SourceID: "session-7", StartUnitID: 20, EndUnitID: 20}},
|
||||
},
|
||||
}
|
||||
digest, err := source.DigestDocument(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document.Digest = digest
|
||||
plan := source.ChunkPlan{
|
||||
SourceDigest: digest,
|
||||
Annotations: source.ChunkAnnotations{"dnd/scenes": json.RawMessage(`{"title":"Gate"}`)},
|
||||
Ranges: []source.ChunkRange{
|
||||
{StartUnitID: 10, EndUnitID: 3, Annotations: source.ChunkAnnotations{"dnd/scenes": json.RawMessage(`{"kind":"narrative"}`)}},
|
||||
{StartUnitID: 20, EndUnitID: 20},
|
||||
},
|
||||
}
|
||||
chunks, err := source.MaterializeChunkPlan(document, plan)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return BuildRequest{
|
||||
Source: document, Plan: plan, Chunks: chunks, RequestedChunker: "dnd/scenes",
|
||||
Producer: Producer{InputModule: "seriatim", ChunkModule: "dnd/scenes", LLMProfile: "dnd-scenes"},
|
||||
}
|
||||
}
|
||||
51
internal/framework/chunkmap/model.go
Normal file
51
internal/framework/chunkmap/model.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Package chunkmap owns the durable accepted source chunk-map contract.
|
||||
package chunkmap
|
||||
|
||||
import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
const (
|
||||
ArtifactKind contracts.ArtifactKind = "source/chunk-map"
|
||||
SchemaID = "notarius.source.chunk_map"
|
||||
SchemaName = "notarius_source_chunk_map_v1"
|
||||
SchemaVersion = "v1"
|
||||
MediaType = "application/json"
|
||||
)
|
||||
|
||||
// ChunkMap is the durable representation of one accepted materialized chunk plan.
|
||||
type ChunkMap struct {
|
||||
SourceID string `json:"source_id"`
|
||||
SourceDigest string `json:"source_digest"`
|
||||
PlanDigest string `json:"plan_digest"`
|
||||
RequestedChunker string `json:"requested_chunker"`
|
||||
Producer Producer `json:"producer"`
|
||||
PlanAnnotations source.ChunkAnnotations `json:"plan_annotations"`
|
||||
Chunks []Chunk `json:"chunks"`
|
||||
}
|
||||
|
||||
// Producer identifies the component that produced the accepted logical plan.
|
||||
type Producer struct {
|
||||
InputModule string `json:"input_module"`
|
||||
ChunkModule string `json:"chunk_module"`
|
||||
LLMProfile string `json:"llm_profile,omitempty"`
|
||||
}
|
||||
|
||||
// Chunk describes one accepted materialized range without source content.
|
||||
type Chunk struct {
|
||||
ID string `json:"id"`
|
||||
Index int `json:"index"`
|
||||
SourceRef source.SourceRef `json:"source_ref"`
|
||||
UnitCount int `json:"unit_count"`
|
||||
Annotations source.ChunkAnnotations `json:"annotations"`
|
||||
}
|
||||
|
||||
// BuildRequest supplies the accepted runtime state used to build a chunk map.
|
||||
type BuildRequest struct {
|
||||
Source *source.SourceDocument
|
||||
Plan source.ChunkPlan
|
||||
Chunks []source.Chunk
|
||||
RequestedChunker string
|
||||
Producer Producer
|
||||
}
|
||||
1
internal/framework/chunkmap/testdata/source_chunk_map.v1.json
vendored
Normal file
1
internal/framework/chunkmap/testdata/source_chunk_map.v1.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"source_id":"session-7","source_digest":"sha256:87d04d40537217e2adcbdec841c5873dbc1034c426d83c4578a0431a5ef855f6","plan_digest":"sha256:a6bfc33d52f1c0f4eb3287dd4a00e8672b3d3d9579f9c0366f18a5ff0dba7d14","requested_chunker":"dnd/scenes","producer":{"input_module":"seriatim","chunk_module":"dnd/scenes","llm_profile":"dnd-scenes"},"plan_annotations":{"dnd/scenes":{"title":"Gate"}},"chunks":[{"id":"chunk-000001","index":0,"source_ref":{"source_id":"session-7","start_unit_id":10,"end_unit_id":3},"unit_count":2,"annotations":{"dnd/scenes":{"kind":"narrative"}}},{"id":"chunk-000002","index":1,"source_ref":{"source_id":"session-7","start_unit_id":20,"end_unit_id":20},"unit_count":1,"annotations":{}}]}
|
||||
Reference in New Issue
Block a user