Add canonical chunk plan source model

This commit is contained in:
2026-07-17 23:47:22 +00:00
parent 1c13e1d64a
commit 3bfac14397
16 changed files with 755 additions and 73 deletions

View File

@@ -1,6 +1,6 @@
# ADR-0005: Cache one canonical chunk plan per source
**Status:** Proposed
**Status:** Accepted
**Date:** 2026-07-17
## Context
@@ -70,6 +70,9 @@ Notarius state.
One mutable active plan is stored under the canonical source identity and
retains provenance for the module and relevant runtime inputs that produced it.
Refreshing the active plan atomically replaces that one mutable record; readers
must observe either the previous complete plan or the replacement complete
plan, never a partial update.
The effective plan producer is reported separately from the chunk module
requested by the current pipeline; reuse must not attribute cached boundaries
or annotations to a module that did not produce them.

View File

@@ -284,7 +284,7 @@ then fills candidate and producer fields when a plan reaches materialization.
## Stage 1: Finalize ADR and add the source-zone plan model
**Status:** Not started
**Status:** Complete
### Objective

View File

@@ -0,0 +1,213 @@
package source
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
)
// CanonicalizeChunkAnnotations validates annotation namespaces and JSON values
// and returns an independently owned map whose values use canonical JSON bytes.
func CanonicalizeChunkAnnotations(annotations ChunkAnnotations) (ChunkAnnotations, error) {
if len(annotations) == 0 {
return nil, nil
}
canonical := make(ChunkAnnotations, len(annotations))
for namespace, raw := range annotations {
if strings.TrimSpace(namespace) == "" {
return nil, fmt.Errorf("chunk annotation namespace must not be empty")
}
if strings.TrimSpace(namespace) != namespace {
return nil, fmt.Errorf("chunk annotation namespace %q must not contain leading or trailing whitespace", namespace)
}
value, err := decodeAnnotation(raw)
if err != nil {
return nil, fmt.Errorf("chunk annotation %q: %w", namespace, err)
}
encoded, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("chunk annotation %q contains an unsupported value: %w", namespace, err)
}
canonical[namespace] = encoded
}
return canonical, nil
}
func decodeAnnotation(raw json.RawMessage) (any, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return nil, fmt.Errorf("must contain valid JSON: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return nil, fmt.Errorf("must contain exactly one JSON value")
}
return nil, fmt.Errorf("must contain exactly one JSON value: %w", err)
}
return value, nil
}
// ValidateChunkAnnotations requires annotations to already contain canonical
// JSON. CanonicalizeChunkAnnotations can be used at producer boundaries.
func ValidateChunkAnnotations(annotations ChunkAnnotations) error {
canonical, err := CanonicalizeChunkAnnotations(annotations)
if err != nil {
return err
}
for namespace, raw := range annotations {
if !bytes.Equal(raw, canonical[namespace]) {
return fmt.Errorf("chunk annotation %q must use canonical JSON", namespace)
}
}
return nil
}
// CloneChunkAnnotations returns a deep clone, including every raw JSON value.
func CloneChunkAnnotations(annotations ChunkAnnotations) ChunkAnnotations {
if len(annotations) == 0 {
return nil
}
cloned := make(ChunkAnnotations, len(annotations))
for namespace, raw := range annotations {
cloned[namespace] = append(json.RawMessage(nil), raw...)
}
return cloned
}
// CloneChunkPlan returns a deep clone of a chunk plan.
func CloneChunkPlan(plan ChunkPlan) ChunkPlan {
cloned := ChunkPlan{
SourceDigest: plan.SourceDigest,
Ranges: make([]ChunkRange, len(plan.Ranges)),
Annotations: CloneChunkAnnotations(plan.Annotations),
}
for i, chunkRange := range plan.Ranges {
cloned.Ranges[i] = ChunkRange{
StartUnitID: chunkRange.StartUnitID,
EndUnitID: chunkRange.EndUnitID,
Annotations: CloneChunkAnnotations(chunkRange.Annotations),
}
}
return cloned
}
// CanonicalizeChunkPlan returns a deep clone with canonical annotation bytes.
func CanonicalizeChunkPlan(plan ChunkPlan) (ChunkPlan, error) {
canonical := CloneChunkPlan(plan)
annotations, err := CanonicalizeChunkAnnotations(plan.Annotations)
if err != nil {
return ChunkPlan{}, fmt.Errorf("chunk plan annotations: %w", err)
}
canonical.Annotations = annotations
for i := range plan.Ranges {
annotations, err := CanonicalizeChunkAnnotations(plan.Ranges[i].Annotations)
if err != nil {
return ChunkPlan{}, fmt.Errorf("chunk plan range[%d] annotations: %w", i, err)
}
canonical.Ranges[i].Annotations = annotations
}
return canonical, nil
}
// ValidateChunkPlan validates a canonical plan against the current source.
// Ranges may contain gaps or overlap, but their start positions must increase.
func ValidateChunkPlan(doc *SourceDocument, plan ChunkPlan) error {
if err := ValidateDocument(doc); err != nil {
return fmt.Errorf("source document: %w", err)
}
if plan.SourceDigest != doc.Digest {
return fmt.Errorf("chunk plan source_digest %q does not match source document digest %q", plan.SourceDigest, doc.Digest)
}
if len(plan.Ranges) == 0 {
return fmt.Errorf("chunk plan ranges must not be empty")
}
if err := ValidateChunkAnnotations(plan.Annotations); err != nil {
return fmt.Errorf("chunk plan annotations: %w", err)
}
previousStart := -1
for i, chunkRange := range plan.Ranges {
start, ok := UnitIndex(doc, chunkRange.StartUnitID)
if !ok {
return fmt.Errorf("chunk plan range[%d] start_unit_id %d was not found", i, chunkRange.StartUnitID)
}
end, ok := UnitIndex(doc, chunkRange.EndUnitID)
if !ok {
return fmt.Errorf("chunk plan range[%d] end_unit_id %d was not found", i, chunkRange.EndUnitID)
}
if start > end {
return fmt.Errorf("chunk plan range[%d] start_unit_id %d appears after end_unit_id %d", i, chunkRange.StartUnitID, chunkRange.EndUnitID)
}
if start <= previousStart {
return fmt.Errorf("chunk plan range[%d] start_unit_id %d does not appear after the previous range start", i, chunkRange.StartUnitID)
}
if err := ValidateChunkAnnotations(chunkRange.Annotations); err != nil {
return fmt.Errorf("chunk plan range[%d] annotations: %w", i, err)
}
previousStart = start
}
return nil
}
// MaterializeChunkPlan deterministically expands a validated plan into chunks.
func MaterializeChunkPlan(doc *SourceDocument, plan ChunkPlan) ([]Chunk, error) {
if err := ValidateChunkPlan(doc, plan); err != nil {
return nil, err
}
chunks := make([]Chunk, 0, len(plan.Ranges))
for index, chunkRange := range plan.Ranges {
start, _ := UnitIndex(doc, chunkRange.StartUnitID)
end, _ := UnitIndex(doc, chunkRange.EndUnitID)
units := cloneSourceUnits(doc.Units[start : end+1])
content, err := json.Marshal(struct {
Units []SourceUnit `json:"units"`
}{Units: units})
if err != nil {
return nil, fmt.Errorf("encode chunk plan range[%d]: %w", index, err)
}
chunks = append(chunks, Chunk{
ID: fmt.Sprintf("chunk-%06d", index+1),
SourceID: doc.ID,
Index: index,
Ref: SourceRef{SourceID: doc.ID, StartUnitID: chunkRange.StartUnitID, EndUnitID: chunkRange.EndUnitID},
Content: content,
MediaType: "application/json",
Units: units,
Metadata: map[string]any{
"start_unit_id": chunkRange.StartUnitID,
"end_unit_id": chunkRange.EndUnitID,
"unit_count": len(units),
},
Annotations: CloneChunkAnnotations(chunkRange.Annotations),
PlanAnnotations: CloneChunkAnnotations(plan.Annotations),
})
}
return chunks, nil
}
func cloneSourceUnits(units []SourceUnit) []SourceUnit {
if len(units) == 0 {
return nil
}
cloned := make([]SourceUnit, len(units))
for i, unit := range units {
cloned[i] = unit
cloned[i].Metadata = cloneJSONMap(unit.Metadata)
}
return cloned
}
func cloneJSONMap(values map[string]any) map[string]any {
if len(values) == 0 {
return nil
}
cloned := make(map[string]any, len(values))
for key, value := range values {
cloned[key] = value
}
return cloned
}

View File

@@ -0,0 +1,230 @@
package source
import (
"bytes"
"encoding/json"
"reflect"
"strings"
"testing"
)
func TestCanonicalizeChunkAnnotations(t *testing.T) {
original := ChunkAnnotations{
"domain/items": json.RawMessage(` { "z": [3, 2, 1], "a": 1.0 } `),
}
canonical, err := CanonicalizeChunkAnnotations(original)
if err != nil {
t.Fatalf("CanonicalizeChunkAnnotations() error = %v, want nil", err)
}
if got, want := string(canonical["domain/items"]), `{"a":1.0,"z":[3,2,1]}`; got != want {
t.Fatalf("canonical annotation = %q, want %q", got, want)
}
original["domain/items"][0] = '['
if got := string(canonical["domain/items"]); got != `{"a":1.0,"z":[3,2,1]}` {
t.Fatalf("canonical annotation changed after input mutation: %q", got)
}
canonical["domain/items"][0] = '['
if original["domain/items"][0] == '[' && bytes.Equal(original["domain/items"], canonical["domain/items"]) {
t.Fatal("input and canonical annotation share value storage")
}
}
func TestCanonicalizeChunkAnnotationsRejectsInvalidValues(t *testing.T) {
tests := []struct {
name string
annotations ChunkAnnotations
want string
}{
{name: "blank namespace", annotations: ChunkAnnotations{" \t": json.RawMessage(`true`)}, want: "namespace must not be empty"},
{name: "untrimmed namespace", annotations: ChunkAnnotations{" items ": json.RawMessage(`true`)}, want: "leading or trailing whitespace"},
{name: "invalid JSON", annotations: ChunkAnnotations{"items": json.RawMessage(`{"x":`)}, want: "valid JSON"},
{name: "trailing JSON", annotations: ChunkAnnotations{"items": json.RawMessage(`true false`)}, want: "exactly one JSON value"},
{name: "non-finite number", annotations: ChunkAnnotations{"items": json.RawMessage(`NaN`)}, want: "valid JSON"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := CanonicalizeChunkAnnotations(tt.annotations)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("CanonicalizeChunkAnnotations() error = %v, want containing %q", err, tt.want)
}
})
}
}
func TestValidateChunkAnnotationsRequiresCanonicalJSON(t *testing.T) {
if err := ValidateChunkAnnotations(ChunkAnnotations{"items": json.RawMessage(` {"b":2,"a":1}`)}); err == nil || !strings.Contains(err.Error(), "canonical JSON") {
t.Fatalf("ValidateChunkAnnotations() error = %v, want canonical JSON error", err)
}
if err := ValidateChunkAnnotations(ChunkAnnotations{"items": json.RawMessage(`{"a":1,"b":2}`)}); err != nil {
t.Fatalf("ValidateChunkAnnotations(canonical) error = %v, want nil", err)
}
}
func TestCloneChunkPlanDoesNotShareAnnotationBytes(t *testing.T) {
plan := validChunkPlan(planDocument())
cloned := CloneChunkPlan(plan)
cloned.Annotations["plan"][0] = '['
cloned.Ranges[0].Annotations["range"][0] = '['
if string(plan.Annotations["plan"]) != `{"value":1}` || string(plan.Ranges[0].Annotations["range"]) != `{"value":2}` {
t.Fatal("CloneChunkPlan() shares annotation value storage")
}
}
func TestValidateChunkPlanRanges(t *testing.T) {
doc := planDocument()
tests := []struct {
name string
mutate func(*ChunkPlan)
want string
}{
{name: "source mismatch", mutate: func(plan *ChunkPlan) { plan.SourceDigest = "sha256:other" }, want: "does not match"},
{name: "missing ranges", mutate: func(plan *ChunkPlan) { plan.Ranges = nil }, want: "ranges must not be empty"},
{name: "missing start", mutate: func(plan *ChunkPlan) { plan.Ranges[0].StartUnitID = 99 }, want: "start_unit_id 99 was not found"},
{name: "missing end", mutate: func(plan *ChunkPlan) { plan.Ranges[0].EndUnitID = 99 }, want: "end_unit_id 99 was not found"},
{name: "backward range", mutate: func(plan *ChunkPlan) { plan.Ranges[0] = ChunkRange{StartUnitID: 30, EndUnitID: 10} }, want: "appears after end_unit_id"},
{name: "duplicate start", mutate: func(plan *ChunkPlan) { plan.Ranges[1].StartUnitID = plan.Ranges[0].StartUnitID }, want: "does not appear after"},
{name: "backward starts", mutate: func(plan *ChunkPlan) {
plan.Ranges = []ChunkRange{{StartUnitID: 30, EndUnitID: 50}, {StartUnitID: 20, EndUnitID: 40}}
}, want: "does not appear after"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plan := validChunkPlan(doc)
tt.mutate(&plan)
err := ValidateChunkPlan(doc, plan)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("ValidateChunkPlan() error = %v, want containing %q", err, tt.want)
}
})
}
for name, ranges := range map[string][]ChunkRange{
"gap": {{StartUnitID: 10, EndUnitID: 20}, {StartUnitID: 40, EndUnitID: 50}},
"overlap": {{StartUnitID: 10, EndUnitID: 30}, {StartUnitID: 20, EndUnitID: 50}},
} {
t.Run(name, func(t *testing.T) {
plan := validChunkPlan(doc)
plan.Ranges = ranges
if err := ValidateChunkPlan(doc, plan); err != nil {
t.Fatalf("ValidateChunkPlan() error = %v, want nil", err)
}
})
}
}
func TestDigestChunkPlanIsStableAndCoversLogicalPlan(t *testing.T) {
doc := planDocument()
plan := validChunkPlan(doc)
first, err := DigestChunkPlan(plan)
if err != nil {
t.Fatalf("DigestChunkPlan() error = %v, want nil", err)
}
reformatted := CloneChunkPlan(plan)
reformatted.Annotations["plan"] = json.RawMessage(` { "value" : 1 } `)
second, err := DigestChunkPlan(reformatted)
if err != nil {
t.Fatalf("DigestChunkPlan(reformatted) error = %v, want nil", err)
}
if first != second {
t.Fatalf("digests = %q and %q, want stable canonical annotation digest", first, second)
}
changes := []func(*ChunkPlan){
func(value *ChunkPlan) { value.Ranges[0].EndUnitID = 30 },
func(value *ChunkPlan) { value.Annotations["plan"] = json.RawMessage(`{"value":2}`) },
func(value *ChunkPlan) { value.Ranges[0].Annotations["range"] = json.RawMessage(`{"value":3}`) },
}
for i, change := range changes {
changed := CloneChunkPlan(plan)
change(&changed)
digest, err := DigestChunkPlan(changed)
if err != nil {
t.Fatalf("DigestChunkPlan(change %d) error = %v", i, err)
}
if digest == first {
t.Fatalf("DigestChunkPlan(change %d) = %q, want changed digest", i, digest)
}
}
}
func TestMaterializeChunkPlanExactOutputAndMutationSafety(t *testing.T) {
doc := planDocument()
plan := validChunkPlan(doc)
plan.Ranges = []ChunkRange{
{StartUnitID: 10, EndUnitID: 30, Annotations: ChunkAnnotations{"range": json.RawMessage(`{"value":2}`)}},
{StartUnitID: 20, EndUnitID: 50, Annotations: ChunkAnnotations{"range": json.RawMessage(`{"value":3}`)}},
}
chunks, err := MaterializeChunkPlan(doc, plan)
if err != nil {
t.Fatalf("MaterializeChunkPlan() error = %v, want nil", err)
}
if len(chunks) != 2 {
t.Fatalf("chunks = %d, want 2", len(chunks))
}
first := chunks[0]
if first.ID != "chunk-000001" || first.SourceID != doc.ID || first.Index != 0 || first.MediaType != "application/json" {
t.Fatalf("first chunk identity = %#v", first)
}
if want := (SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 30}); first.Ref != want {
t.Fatalf("first ref = %#v, want %#v", first.Ref, want)
}
if got, want := unitIDs(first.Units), []int{10, 20, 30}; !reflect.DeepEqual(got, want) {
t.Fatalf("first unit ids = %#v, want %#v", got, want)
}
wantContent, _ := json.Marshal(struct {
Units []SourceUnit `json:"units"`
}{Units: doc.Units[:3]})
if !bytes.Equal(first.Content, wantContent) {
t.Fatalf("first content = %s, want %s", first.Content, wantContent)
}
if !reflect.DeepEqual(first.Metadata, map[string]any{"start_unit_id": 10, "end_unit_id": 30, "unit_count": 3}) {
t.Fatalf("first metadata = %#v", first.Metadata)
}
if string(first.Annotations["range"]) != `{"value":2}` || string(first.PlanAnnotations["plan"]) != `{"value":1}` {
t.Fatalf("first annotations = %#v / %#v", first.Annotations, first.PlanAnnotations)
}
if got, want := unitIDs(chunks[1].Units), []int{20, 30, 40, 50}; !reflect.DeepEqual(got, want) {
t.Fatalf("overlapping unit ids = %#v, want %#v", got, want)
}
again, err := MaterializeChunkPlan(doc, plan)
if err != nil {
t.Fatalf("MaterializeChunkPlan(repeated) error = %v", err)
}
if !reflect.DeepEqual(chunks, again) {
t.Fatalf("repeated materialization differs:\nfirst: %#v\nagain: %#v", chunks, again)
}
chunks[0].Units[0].Text = "mutated"
chunks[0].Annotations["range"][0] = '['
chunks[0].PlanAnnotations["plan"][0] = '['
if doc.Units[0].Text == "mutated" || string(plan.Ranges[0].Annotations["range"]) != `{"value":2}` || string(plan.Annotations["plan"]) != `{"value":1}` {
t.Fatal("materialized chunk shares owned plan or source storage")
}
}
func planDocument() *SourceDocument {
doc := &SourceDocument{ID: "source-plan", Kind: "test", Format: "application/test", Digest: "sha256:source-plan"}
for _, id := range []int{10, 20, 30, 40, 50} {
doc.Units = append(doc.Units, SourceUnit{ID: id, Kind: "line", Text: "unit", Ref: SourceRef{SourceID: doc.ID, StartUnitID: id, EndUnitID: id}})
}
return doc
}
func validChunkPlan(doc *SourceDocument) ChunkPlan {
return ChunkPlan{
SourceDigest: doc.Digest,
Ranges: []ChunkRange{
{StartUnitID: 10, EndUnitID: 20, Annotations: ChunkAnnotations{"range": json.RawMessage(`{"value":2}`)}},
{StartUnitID: 30, EndUnitID: 50},
},
Annotations: ChunkAnnotations{"plan": json.RawMessage(`{"value":1}`)},
}
}
func unitIDs(units []SourceUnit) []int {
ids := make([]int, len(units))
for i, unit := range units {
ids[i] = unit.ID
}
return ids
}

View File

@@ -37,24 +37,36 @@ func DigestDocument(doc *SourceDocument) (string, error) {
// DigestChunk returns a deterministic digest of a chunk, including its source
// provenance, content, units, and metadata.
func DigestChunk(chunk Chunk) (string, error) {
annotations, err := CanonicalizeChunkAnnotations(chunk.Annotations)
if err != nil {
return "", fmt.Errorf("canonicalize source chunk annotations: %w", err)
}
planAnnotations, err := CanonicalizeChunkAnnotations(chunk.PlanAnnotations)
if err != nil {
return "", fmt.Errorf("canonicalize source chunk plan annotations: %w", err)
}
payload := struct {
ID string `json:"id"`
SourceID string `json:"source_id"`
Index int `json:"index"`
Ref SourceRef `json:"ref"`
Content []byte `json:"content"`
MediaType string `json:"media_type"`
Units []SourceUnit `json:"units"`
Metadata map[string]any `json:"metadata,omitempty"`
ID string `json:"id"`
SourceID string `json:"source_id"`
Index int `json:"index"`
Ref SourceRef `json:"ref"`
Content []byte `json:"content"`
MediaType string `json:"media_type"`
Units []SourceUnit `json:"units"`
Metadata map[string]any `json:"metadata,omitempty"`
Annotations ChunkAnnotations `json:"annotations,omitempty"`
PlanAnnotations ChunkAnnotations `json:"plan_annotations,omitempty"`
}{
ID: chunk.ID,
SourceID: chunk.SourceID,
Index: chunk.Index,
Ref: chunk.Ref,
Content: chunk.Content,
MediaType: chunk.MediaType,
Units: chunk.Units,
Metadata: chunk.Metadata,
ID: chunk.ID,
SourceID: chunk.SourceID,
Index: chunk.Index,
Ref: chunk.Ref,
Content: chunk.Content,
MediaType: chunk.MediaType,
Units: chunk.Units,
Metadata: chunk.Metadata,
Annotations: annotations,
PlanAnnotations: planAnnotations,
}
encoded, err := json.Marshal(payload)
if err != nil {
@@ -63,3 +75,28 @@ func DigestChunk(chunk Chunk) (string, error) {
sum := sha256.Sum256(encoded)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
// DigestChunkPlan returns a deterministic digest of the logical plan. Storage
// schema, producer provenance, warnings, and timestamps are intentionally not
// part of the digest.
func DigestChunkPlan(plan ChunkPlan) (string, error) {
canonical, err := CanonicalizeChunkPlan(plan)
if err != nil {
return "", err
}
if isBlank(canonical.SourceDigest) {
return "", fmt.Errorf("chunk plan source_digest must not be empty")
}
if hasSurroundingWhitespace(canonical.SourceDigest) {
return "", fmt.Errorf("chunk plan source_digest must not contain leading or trailing whitespace")
}
if len(canonical.Ranges) == 0 {
return "", fmt.Errorf("chunk plan ranges must not be empty")
}
encoded, err := json.Marshal(canonical)
if err != nil {
return "", fmt.Errorf("encode chunk plan for digest: %w", err)
}
sum := sha256.Sum256(encoded)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}

View File

@@ -1,5 +1,7 @@
package source
import "encoding/json"
type SourceDocument struct {
ID string `json:"id"`
Kind string `json:"kind"`
@@ -23,13 +25,29 @@ type SourceRef struct {
EndUnitID int `json:"end_unit_id"`
}
type Chunk struct {
ID string `json:"id"`
SourceID string `json:"source_id"`
Index int `json:"index"`
Ref SourceRef `json:"ref"`
Content []byte `json:"-"`
MediaType string `json:"media_type"`
Units []SourceUnit `json:"units"`
Metadata map[string]any `json:"metadata,omitempty"`
type ChunkAnnotations map[string]json.RawMessage
type ChunkPlan struct {
SourceDigest string `json:"source_digest"`
Ranges []ChunkRange `json:"ranges"`
Annotations ChunkAnnotations `json:"annotations,omitempty"`
}
type ChunkRange struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
Annotations ChunkAnnotations `json:"annotations,omitempty"`
}
type Chunk struct {
ID string `json:"id"`
SourceID string `json:"source_id"`
Index int `json:"index"`
Ref SourceRef `json:"ref"`
Content []byte `json:"-"`
MediaType string `json:"media_type"`
Units []SourceUnit `json:"units"`
Metadata map[string]any `json:"metadata,omitempty"`
Annotations ChunkAnnotations `json:"annotations,omitempty"`
PlanAnnotations ChunkAnnotations `json:"plan_annotations,omitempty"`
}

View File

@@ -1,6 +1,7 @@
package source
import (
"encoding/json"
"strings"
"testing"
)
@@ -258,6 +259,28 @@ func TestDigestChunkIsDeterministicAndIncludesReference(t *testing.T) {
}
}
func TestDigestChunkIncludesAnnotationScopes(t *testing.T) {
doc := validDocument()
chunk := Chunk{
ID: "chunk-1", SourceID: doc.ID, Ref: SourceRef{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 2},
Content: []byte("content"), MediaType: "text/plain", Units: doc.Units,
Annotations: ChunkAnnotations{"scope": json.RawMessage(`{"value":1}`)},
PlanAnnotations: ChunkAnnotations{"scope": json.RawMessage(`{"value":2}`)},
}
base, err := DigestChunk(chunk)
if err != nil {
t.Fatalf("DigestChunk() error = %v", err)
}
chunk.Annotations["scope"] = json.RawMessage(`{"value":3}`)
rangeChanged, _ := DigestChunk(chunk)
chunk.Annotations["scope"] = json.RawMessage(`{"value":1}`)
chunk.PlanAnnotations["scope"] = json.RawMessage(`{"value":3}`)
planChanged, _ := DigestChunk(chunk)
if base == rangeChanged || base == planChanged || rangeChanged == planChanged {
t.Fatalf("annotation scope digests did not change distinctly: %q %q %q", base, rangeChanged, planChanged)
}
}
func TestValidateRefValid(t *testing.T) {
doc := validDocument()
ref := SourceRef{

View File

@@ -242,15 +242,25 @@ func sourceChunksFromEnvelope(values []chunkEnvelope) ([]source.Chunk, error) {
if err != nil {
return nil, err
}
annotations, err := source.CanonicalizeChunkAnnotations(value.Annotations)
if err != nil {
return nil, fmt.Errorf("canonicalize chunk %q annotations: %w", value.ID, err)
}
planAnnotations, err := source.CanonicalizeChunkAnnotations(value.PlanAnnotations)
if err != nil {
return nil, fmt.Errorf("canonicalize chunk %q plan_annotations: %w", value.ID, err)
}
out = append(out, source.Chunk{
ID: value.ID,
SourceID: value.SourceID,
Index: value.Index,
Ref: value.Ref,
Content: content,
MediaType: value.Content.MediaType,
Units: cloneSourceUnits(value.Units),
Metadata: cloneMetadata(value.Metadata),
ID: value.ID,
SourceID: value.SourceID,
Index: value.Index,
Ref: value.Ref,
Content: content,
MediaType: value.Content.MediaType,
Units: cloneSourceUnits(value.Units),
Metadata: cloneMetadata(value.Metadata),
Annotations: annotations,
PlanAnnotations: planAnnotations,
})
}
return out, nil

View File

@@ -251,13 +251,15 @@ type chunksEnvelope struct {
}
type chunkEnvelope struct {
ID string `json:"id"`
SourceID string `json:"source_id"`
Index int `json:"index"`
Ref source.SourceRef `json:"ref"`
Content binaryEnvelope `json:"content"`
Units []source.SourceUnit `json:"units,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
ID string `json:"id"`
SourceID string `json:"source_id"`
Index int `json:"index"`
Ref source.SourceRef `json:"ref"`
Content binaryEnvelope `json:"content"`
Units []source.SourceUnit `json:"units,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
Annotations source.ChunkAnnotations `json:"annotations,omitempty"`
PlanAnnotations source.ChunkAnnotations `json:"plan_annotations,omitempty"`
}
type binaryEnvelope struct {
@@ -320,13 +322,15 @@ func chunkEnvelopes(chunks []source.Chunk) []chunkEnvelope {
out := make([]chunkEnvelope, 0, len(chunks))
for _, chunk := range chunks {
out = append(out, chunkEnvelope{
ID: chunk.ID,
SourceID: chunk.SourceID,
Index: chunk.Index,
Ref: chunk.Ref,
Content: binaryEnvelopeFromContent(chunk.Content, chunk.MediaType, chunk.Metadata, nil),
Units: cloneSourceUnits(chunk.Units),
Metadata: cloneMetadata(chunk.Metadata),
ID: chunk.ID,
SourceID: chunk.SourceID,
Index: chunk.Index,
Ref: chunk.Ref,
Content: binaryEnvelopeFromContent(chunk.Content, chunk.MediaType, chunk.Metadata, nil),
Units: cloneSourceUnits(chunk.Units),
Metadata: cloneMetadata(chunk.Metadata),
Annotations: source.CloneChunkAnnotations(chunk.Annotations),
PlanAnnotations: source.CloneChunkAnnotations(chunk.PlanAnnotations),
})
}
return out

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -33,6 +34,12 @@ func TestWorkspaceRecorderWritesSuccessfulCheckpointFiles(t *testing.T) {
Content: []byte("chunk content"),
MediaType: "text/plain",
Units: doc.Units,
Annotations: source.ChunkAnnotations{
"same": json.RawMessage(`{"range":1}`),
},
PlanAnnotations: source.ChunkAnnotations{
"same": json.RawMessage(`{"plan":2}`),
},
},
}
@@ -57,7 +64,9 @@ func TestWorkspaceRecorderWritesSuccessfulCheckpointFiles(t *testing.T) {
assertManifestStatus(t, filepath.Join(root, "chunk", "manifest.json"), coreworkspace.StatusSucceeded)
var chunkPayload struct {
Chunks []struct {
Content struct {
Annotations source.ChunkAnnotations `json:"annotations"`
PlanAnnotations source.ChunkAnnotations `json:"plan_annotations"`
Content struct {
ContentBase64 string `json:"content_base64"`
ContentDigest string `json:"content_digest"`
} `json:"content"`
@@ -77,6 +86,28 @@ func TestWorkspaceRecorderWritesSuccessfulCheckpointFiles(t *testing.T) {
if got, want := chunkPayload.Chunks[0].Content.ContentDigest, contentDigest([]byte("chunk content")); got != want {
t.Fatalf("content digest = %q, want %q", got, want)
}
if !jsonEqual(t, chunkPayload.Chunks[0].Annotations["same"], json.RawMessage(`{"range":1}`)) || !jsonEqual(t, chunkPayload.Chunks[0].PlanAnnotations["same"], json.RawMessage(`{"plan":2}`)) {
t.Fatalf("checkpoint annotations = %s / %s", chunkPayload.Chunks[0].Annotations["same"], chunkPayload.Chunks[0].PlanAnnotations["same"])
}
loaded, decision := (&WorkspaceLoader{root: root}).Chunk("generic", doc.Digest)
if !decision.Reused || len(loaded.Chunks) != 1 {
t.Fatalf("loaded chunk checkpoint = %#v, decision = %#v", loaded, decision)
}
if string(loaded.Chunks[0].Annotations["same"]) != `{"range":1}` || string(loaded.Chunks[0].PlanAnnotations["same"]) != `{"plan":2}` {
t.Fatalf("loaded canonical annotations = %s / %s", loaded.Chunks[0].Annotations["same"], loaded.Chunks[0].PlanAnnotations["same"])
}
}
func jsonEqual(t *testing.T, left, right []byte) bool {
t.Helper()
var leftValue, rightValue any
if err := json.Unmarshal(left, &leftValue); err != nil {
t.Fatalf("decode left JSON: %v", err)
}
if err := json.Unmarshal(right, &rightValue); err != nil {
t.Fatalf("decode right JSON: %v", err)
}
return reflect.DeepEqual(leftValue, rightValue)
}
func TestWorkspaceArtifactCheckpointsRoundTripCodecIdentityAndBytes(t *testing.T) {

View File

@@ -0,0 +1,70 @@
package pipeline
import (
"encoding/json"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
func TestChunkCanonicalizationAndClonePreserveAnnotationScopes(t *testing.T) {
doc := validSourceDocument()
chunk := source.Chunk{
ID: "chunk-1", SourceID: doc.ID, Index: 0,
Ref: doc.Units[0].Ref, Content: []byte(`{"units":[1]}`), MediaType: "application/json", Units: doc.Units[:1],
Annotations: source.ChunkAnnotations{"shared": json.RawMessage(` {"range": true} `)},
PlanAnnotations: source.ChunkAnnotations{"shared": json.RawMessage(` {"plan": true} `)},
}
canonical, err := validateAndCanonicalizeChunkResult(doc, []source.Chunk{chunk})
if err != nil {
t.Fatalf("validateAndCanonicalizeChunkResult() error = %v", err)
}
if got := string(canonical[0].Annotations["shared"]); got != `{"range":true}` {
t.Fatalf("range annotation = %q", got)
}
if got := string(canonical[0].PlanAnnotations["shared"]); got != `{"plan":true}` {
t.Fatalf("plan annotation = %q", got)
}
cloned := cloneSourceChunk(canonical[0])
cloned.Annotations["shared"][0] = '['
cloned.PlanAnnotations["shared"][0] = '['
if string(canonical[0].Annotations["shared"]) != `{"range":true}` || string(canonical[0].PlanAnnotations["shared"]) != `{"plan":true}` {
t.Fatal("cloneSourceChunk() shares annotation bytes")
}
}
func TestSerializedChunkValidationRepresentationIncludesAnnotationScopes(t *testing.T) {
doc := validSourceDocument()
chunks := []source.Chunk{{
ID: "chunk-1", SourceID: doc.ID, Ref: doc.Units[0].Ref, MediaType: "application/json", Units: doc.Units[:1],
Annotations: source.ChunkAnnotations{"same": json.RawMessage(`{"range":1}`)},
PlanAnnotations: source.ChunkAnnotations{"same": json.RawMessage(`{"plan":2}`)},
}}
encoded, err := json.Marshal(chunks)
if err != nil {
t.Fatalf("json.Marshal(chunks) error = %v", err)
}
got := string(encoded)
if !strings.Contains(got, `"annotations":{"same":{"range":1}}`) || !strings.Contains(got, `"plan_annotations":{"same":{"plan":2}}`) {
t.Fatalf("serialized chunks = %s, want both annotation scopes", got)
}
}
func TestDebugChunkEnvelopeClonesAnnotationScopes(t *testing.T) {
chunk := source.Chunk{
ID: "chunk-1", SourceID: "source-1",
Annotations: source.ChunkAnnotations{"same": json.RawMessage(`{"range":1}`)},
PlanAnnotations: source.ChunkAnnotations{"same": json.RawMessage(`{"plan":2}`)},
}
envelope := debugSourceChunkEnvelope(chunk)
if string(envelope.Annotations["same"]) != `{"range":1}` || string(envelope.PlanAnnotations["same"]) != `{"plan":2}` {
t.Fatalf("debug annotations = %#v / %#v", envelope.Annotations, envelope.PlanAnnotations)
}
envelope.Annotations["same"][0] = '['
envelope.PlanAnnotations["same"][0] = '['
if string(chunk.Annotations["same"]) != `{"range":1}` || string(chunk.PlanAnnotations["same"]) != `{"plan":2}` {
t.Fatal("debugSourceChunkEnvelope() shares annotation bytes")
}
}

View File

@@ -48,6 +48,14 @@ func validateAndCanonicalizeChunkResult(doc *source.SourceDocument, chunks []sou
if err := source.ValidateRef(doc, chunk.Ref); err != nil {
return nil, fmt.Errorf("chunk %q ref: %w", chunk.ID, err)
}
annotations, err := source.CanonicalizeChunkAnnotations(chunk.Annotations)
if err != nil {
return nil, fmt.Errorf("chunk %q annotations: %w", chunk.ID, err)
}
planAnnotations, err := source.CanonicalizeChunkAnnotations(chunk.PlanAnnotations)
if err != nil {
return nil, fmt.Errorf("chunk %q plan_annotations: %w", chunk.ID, err)
}
seenUnitIDs := make(map[int]struct{}, len(chunk.Units))
previousSourceIndex := -1
@@ -84,14 +92,16 @@ func validateAndCanonicalizeChunkResult(doc *source.SourceDocument, chunks []sou
}
canonicalChunks = append(canonicalChunks, source.Chunk{
ID: chunk.ID,
SourceID: chunk.SourceID,
Index: chunk.Index,
Ref: expectedRef,
Content: append([]byte(nil), chunk.Content...),
MediaType: chunk.MediaType,
Units: canonicalUnits,
Metadata: cloneMetadata(chunk.Metadata),
ID: chunk.ID,
SourceID: chunk.SourceID,
Index: chunk.Index,
Ref: expectedRef,
Content: append([]byte(nil), chunk.Content...),
MediaType: chunk.MediaType,
Units: canonicalUnits,
Metadata: cloneMetadata(chunk.Metadata),
Annotations: annotations,
PlanAnnotations: planAnnotations,
})
}

View File

@@ -100,13 +100,15 @@ type debugSourceDocument struct {
}
type debugSourceChunk struct {
ID string `json:"id"`
SourceID string `json:"source_id"`
Index int `json:"index"`
Ref source.SourceRef `json:"ref"`
Content debugBinaryEnvelope `json:"content"`
Units []source.SourceUnit `json:"units,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
ID string `json:"id"`
SourceID string `json:"source_id"`
Index int `json:"index"`
Ref source.SourceRef `json:"ref"`
Content debugBinaryEnvelope `json:"content"`
Units []source.SourceUnit `json:"units,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
Annotations source.ChunkAnnotations `json:"annotations,omitempty"`
PlanAnnotations source.ChunkAnnotations `json:"plan_annotations,omitempty"`
}
type debugSerializedOutput struct {
@@ -464,13 +466,15 @@ func debugSourceDocumentEnvelope(doc *source.SourceDocument) *debugSourceDocumen
func debugSourceChunkEnvelope(chunk source.Chunk) debugSourceChunk {
return debugSourceChunk{
ID: chunk.ID,
SourceID: chunk.SourceID,
Index: chunk.Index,
Ref: chunk.Ref,
Content: debugContentEnvelope(chunk.Content, chunk.MediaType, chunk.Metadata, nil),
Units: cloneSourceUnits(chunk.Units),
Metadata: redactSensitiveMap(chunk.Metadata),
ID: chunk.ID,
SourceID: chunk.SourceID,
Index: chunk.Index,
Ref: chunk.Ref,
Content: debugContentEnvelope(chunk.Content, chunk.MediaType, chunk.Metadata, nil),
Units: cloneSourceUnits(chunk.Units),
Metadata: redactSensitiveMap(chunk.Metadata),
Annotations: source.CloneChunkAnnotations(chunk.Annotations),
PlanAnnotations: source.CloneChunkAnnotations(chunk.PlanAnnotations),
}
}

View File

@@ -863,6 +863,8 @@ func cloneSourceChunk(chunk source.Chunk) source.Chunk {
chunk.Content = append([]byte(nil), chunk.Content...)
chunk.Units = cloneSourceUnits(chunk.Units)
chunk.Metadata = cloneMetadata(chunk.Metadata)
chunk.Annotations = source.CloneChunkAnnotations(chunk.Annotations)
chunk.PlanAnnotations = source.CloneChunkAnnotations(chunk.PlanAnnotations)
return chunk
}

View File

@@ -258,7 +258,7 @@ func prepareLaneExtract(input RunInput, loader CheckpointLoader, doc *source.Sou
}
func (r *Runner) runExtractJob(ctx context.Context, input RunInput, doc *source.SourceDocument, sourceInput contracts.LLMInputMaterial, sessionID string, job extractJob) extractJobResult {
state, chunk := job.lane, job.chunk
state, chunk := job.lane, cloneSourceChunk(job.chunk)
lane, typed := state.prepared.resolved, state.prepared.typed
result := extractJobResult{laneIndex: state.index, chunkIndex: chunk.Index}
var accepted erasedExtractArtifact

View File

@@ -2,10 +2,12 @@ package pipeline
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
@@ -50,6 +52,31 @@ func cloneCheckpointArtifacts(values []CheckpointArtifact) []CheckpointArtifact
return cloned
}
func TestRunnerPassesIndependentAnnotationScopesToExtractors(t *testing.T) {
prepared := preparedAttemptDebugPipeline(t)
chunker := prepared.chunker.(*typedTestChunker)
chunker.chunks[0].Annotations = source.ChunkAnnotations{"same": json.RawMessage(`{"range":1}`)}
chunker.chunks[0].PlanAnnotations = source.ChunkAnnotations{"same": json.RawMessage(`{"plan":2}`)}
var gotRange, gotPlan string
installExtractOperation(prepared, 0, func(_ context.Context, request contracts.TypedExtractionRequest) (erasedTypedResult, error) {
gotRange = string(request.Chunk.Annotations["same"])
gotPlan = string(request.Chunk.PlanAnnotations["same"])
request.Chunk.Annotations["same"][0] = '['
request.Chunk.PlanAnnotations["same"][0] = '['
return erasedTypedResult{Value: typedValueForLane(0, request.Chunk.Index)}, nil
})
if _, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input")}); err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
if gotRange != `{"range":1}` || gotPlan != `{"plan":2}` {
t.Fatalf("extract annotations = %q / %q", gotRange, gotPlan)
}
if string(chunker.chunks[0].Annotations["same"]) != `{"range":1}` || string(chunker.chunks[0].PlanAnnotations["same"]) != `{"plan":2}` {
t.Fatal("extract request shares annotation bytes with chunker output")
}
}
func TestRunnerContinuesFromFreshAndReusedExtractResults(t *testing.T) {
prepared := preparedAttemptDebugPipeline(t)
extractCalls := 0