Add canonical chunk plan source model
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# ADR-0005: Cache one canonical chunk plan per source
|
# ADR-0005: Cache one canonical chunk plan per source
|
||||||
|
|
||||||
**Status:** Proposed
|
**Status:** Accepted
|
||||||
**Date:** 2026-07-17
|
**Date:** 2026-07-17
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
@@ -70,6 +70,9 @@ Notarius state.
|
|||||||
|
|
||||||
One mutable active plan is stored under the canonical source identity and
|
One mutable active plan is stored under the canonical source identity and
|
||||||
retains provenance for the module and relevant runtime inputs that produced it.
|
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
|
The effective plan producer is reported separately from the chunk module
|
||||||
requested by the current pipeline; reuse must not attribute cached boundaries
|
requested by the current pipeline; reuse must not attribute cached boundaries
|
||||||
or annotations to a module that did not produce them.
|
or annotations to a module that did not produce them.
|
||||||
|
|||||||
@@ -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
|
## Stage 1: Finalize ADR and add the source-zone plan model
|
||||||
|
|
||||||
**Status:** Not started
|
**Status:** Complete
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
|
|
||||||
|
|||||||
213
internal/core/source/chunk_plan.go
Normal file
213
internal/core/source/chunk_plan.go
Normal 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
|
||||||
|
}
|
||||||
230
internal/core/source/chunk_plan_test.go
Normal file
230
internal/core/source/chunk_plan_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -37,24 +37,36 @@ func DigestDocument(doc *SourceDocument) (string, error) {
|
|||||||
// DigestChunk returns a deterministic digest of a chunk, including its source
|
// DigestChunk returns a deterministic digest of a chunk, including its source
|
||||||
// provenance, content, units, and metadata.
|
// provenance, content, units, and metadata.
|
||||||
func DigestChunk(chunk Chunk) (string, error) {
|
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 {
|
payload := struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
SourceID string `json:"source_id"`
|
SourceID string `json:"source_id"`
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
Ref SourceRef `json:"ref"`
|
Ref SourceRef `json:"ref"`
|
||||||
Content []byte `json:"content"`
|
Content []byte `json:"content"`
|
||||||
MediaType string `json:"media_type"`
|
MediaType string `json:"media_type"`
|
||||||
Units []SourceUnit `json:"units"`
|
Units []SourceUnit `json:"units"`
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
Annotations ChunkAnnotations `json:"annotations,omitempty"`
|
||||||
|
PlanAnnotations ChunkAnnotations `json:"plan_annotations,omitempty"`
|
||||||
}{
|
}{
|
||||||
ID: chunk.ID,
|
ID: chunk.ID,
|
||||||
SourceID: chunk.SourceID,
|
SourceID: chunk.SourceID,
|
||||||
Index: chunk.Index,
|
Index: chunk.Index,
|
||||||
Ref: chunk.Ref,
|
Ref: chunk.Ref,
|
||||||
Content: chunk.Content,
|
Content: chunk.Content,
|
||||||
MediaType: chunk.MediaType,
|
MediaType: chunk.MediaType,
|
||||||
Units: chunk.Units,
|
Units: chunk.Units,
|
||||||
Metadata: chunk.Metadata,
|
Metadata: chunk.Metadata,
|
||||||
|
Annotations: annotations,
|
||||||
|
PlanAnnotations: planAnnotations,
|
||||||
}
|
}
|
||||||
encoded, err := json.Marshal(payload)
|
encoded, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,3 +75,28 @@ func DigestChunk(chunk Chunk) (string, error) {
|
|||||||
sum := sha256.Sum256(encoded)
|
sum := sha256.Sum256(encoded)
|
||||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
type SourceDocument struct {
|
type SourceDocument struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
@@ -23,13 +25,29 @@ type SourceRef struct {
|
|||||||
EndUnitID int `json:"end_unit_id"`
|
EndUnitID int `json:"end_unit_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Chunk struct {
|
type ChunkAnnotations map[string]json.RawMessage
|
||||||
ID string `json:"id"`
|
|
||||||
SourceID string `json:"source_id"`
|
type ChunkPlan struct {
|
||||||
Index int `json:"index"`
|
SourceDigest string `json:"source_digest"`
|
||||||
Ref SourceRef `json:"ref"`
|
Ranges []ChunkRange `json:"ranges"`
|
||||||
Content []byte `json:"-"`
|
Annotations ChunkAnnotations `json:"annotations,omitempty"`
|
||||||
MediaType string `json:"media_type"`
|
}
|
||||||
Units []SourceUnit `json:"units"`
|
|
||||||
Metadata map[string]any `json:"metadata,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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestValidateRefValid(t *testing.T) {
|
||||||
doc := validDocument()
|
doc := validDocument()
|
||||||
ref := SourceRef{
|
ref := SourceRef{
|
||||||
|
|||||||
@@ -242,15 +242,25 @@ func sourceChunksFromEnvelope(values []chunkEnvelope) ([]source.Chunk, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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{
|
out = append(out, source.Chunk{
|
||||||
ID: value.ID,
|
ID: value.ID,
|
||||||
SourceID: value.SourceID,
|
SourceID: value.SourceID,
|
||||||
Index: value.Index,
|
Index: value.Index,
|
||||||
Ref: value.Ref,
|
Ref: value.Ref,
|
||||||
Content: content,
|
Content: content,
|
||||||
MediaType: value.Content.MediaType,
|
MediaType: value.Content.MediaType,
|
||||||
Units: cloneSourceUnits(value.Units),
|
Units: cloneSourceUnits(value.Units),
|
||||||
Metadata: cloneMetadata(value.Metadata),
|
Metadata: cloneMetadata(value.Metadata),
|
||||||
|
Annotations: annotations,
|
||||||
|
PlanAnnotations: planAnnotations,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -251,13 +251,15 @@ type chunksEnvelope struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type chunkEnvelope struct {
|
type chunkEnvelope struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
SourceID string `json:"source_id"`
|
SourceID string `json:"source_id"`
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
Ref source.SourceRef `json:"ref"`
|
Ref source.SourceRef `json:"ref"`
|
||||||
Content binaryEnvelope `json:"content"`
|
Content binaryEnvelope `json:"content"`
|
||||||
Units []source.SourceUnit `json:"units,omitempty"`
|
Units []source.SourceUnit `json:"units,omitempty"`
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
Annotations source.ChunkAnnotations `json:"annotations,omitempty"`
|
||||||
|
PlanAnnotations source.ChunkAnnotations `json:"plan_annotations,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type binaryEnvelope struct {
|
type binaryEnvelope struct {
|
||||||
@@ -320,13 +322,15 @@ func chunkEnvelopes(chunks []source.Chunk) []chunkEnvelope {
|
|||||||
out := make([]chunkEnvelope, 0, len(chunks))
|
out := make([]chunkEnvelope, 0, len(chunks))
|
||||||
for _, chunk := range chunks {
|
for _, chunk := range chunks {
|
||||||
out = append(out, chunkEnvelope{
|
out = append(out, chunkEnvelope{
|
||||||
ID: chunk.ID,
|
ID: chunk.ID,
|
||||||
SourceID: chunk.SourceID,
|
SourceID: chunk.SourceID,
|
||||||
Index: chunk.Index,
|
Index: chunk.Index,
|
||||||
Ref: chunk.Ref,
|
Ref: chunk.Ref,
|
||||||
Content: binaryEnvelopeFromContent(chunk.Content, chunk.MediaType, chunk.Metadata, nil),
|
Content: binaryEnvelopeFromContent(chunk.Content, chunk.MediaType, chunk.Metadata, nil),
|
||||||
Units: cloneSourceUnits(chunk.Units),
|
Units: cloneSourceUnits(chunk.Units),
|
||||||
Metadata: cloneMetadata(chunk.Metadata),
|
Metadata: cloneMetadata(chunk.Metadata),
|
||||||
|
Annotations: source.CloneChunkAnnotations(chunk.Annotations),
|
||||||
|
PlanAnnotations: source.CloneChunkAnnotations(chunk.PlanAnnotations),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -33,6 +34,12 @@ func TestWorkspaceRecorderWritesSuccessfulCheckpointFiles(t *testing.T) {
|
|||||||
Content: []byte("chunk content"),
|
Content: []byte("chunk content"),
|
||||||
MediaType: "text/plain",
|
MediaType: "text/plain",
|
||||||
Units: doc.Units,
|
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)
|
assertManifestStatus(t, filepath.Join(root, "chunk", "manifest.json"), coreworkspace.StatusSucceeded)
|
||||||
var chunkPayload struct {
|
var chunkPayload struct {
|
||||||
Chunks []struct {
|
Chunks []struct {
|
||||||
Content struct {
|
Annotations source.ChunkAnnotations `json:"annotations"`
|
||||||
|
PlanAnnotations source.ChunkAnnotations `json:"plan_annotations"`
|
||||||
|
Content struct {
|
||||||
ContentBase64 string `json:"content_base64"`
|
ContentBase64 string `json:"content_base64"`
|
||||||
ContentDigest string `json:"content_digest"`
|
ContentDigest string `json:"content_digest"`
|
||||||
} `json:"content"`
|
} `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 {
|
if got, want := chunkPayload.Chunks[0].Content.ContentDigest, contentDigest([]byte("chunk content")); got != want {
|
||||||
t.Fatalf("content digest = %q, want %q", 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) {
|
func TestWorkspaceArtifactCheckpointsRoundTripCodecIdentityAndBytes(t *testing.T) {
|
||||||
|
|||||||
70
internal/framework/pipeline/chunk_annotations_test.go
Normal file
70
internal/framework/pipeline/chunk_annotations_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,14 @@ func validateAndCanonicalizeChunkResult(doc *source.SourceDocument, chunks []sou
|
|||||||
if err := source.ValidateRef(doc, chunk.Ref); err != nil {
|
if err := source.ValidateRef(doc, chunk.Ref); err != nil {
|
||||||
return nil, fmt.Errorf("chunk %q ref: %w", chunk.ID, err)
|
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))
|
seenUnitIDs := make(map[int]struct{}, len(chunk.Units))
|
||||||
previousSourceIndex := -1
|
previousSourceIndex := -1
|
||||||
@@ -84,14 +92,16 @@ func validateAndCanonicalizeChunkResult(doc *source.SourceDocument, chunks []sou
|
|||||||
}
|
}
|
||||||
|
|
||||||
canonicalChunks = append(canonicalChunks, source.Chunk{
|
canonicalChunks = append(canonicalChunks, source.Chunk{
|
||||||
ID: chunk.ID,
|
ID: chunk.ID,
|
||||||
SourceID: chunk.SourceID,
|
SourceID: chunk.SourceID,
|
||||||
Index: chunk.Index,
|
Index: chunk.Index,
|
||||||
Ref: expectedRef,
|
Ref: expectedRef,
|
||||||
Content: append([]byte(nil), chunk.Content...),
|
Content: append([]byte(nil), chunk.Content...),
|
||||||
MediaType: chunk.MediaType,
|
MediaType: chunk.MediaType,
|
||||||
Units: canonicalUnits,
|
Units: canonicalUnits,
|
||||||
Metadata: cloneMetadata(chunk.Metadata),
|
Metadata: cloneMetadata(chunk.Metadata),
|
||||||
|
Annotations: annotations,
|
||||||
|
PlanAnnotations: planAnnotations,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,13 +100,15 @@ type debugSourceDocument struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type debugSourceChunk struct {
|
type debugSourceChunk struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
SourceID string `json:"source_id"`
|
SourceID string `json:"source_id"`
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
Ref source.SourceRef `json:"ref"`
|
Ref source.SourceRef `json:"ref"`
|
||||||
Content debugBinaryEnvelope `json:"content"`
|
Content debugBinaryEnvelope `json:"content"`
|
||||||
Units []source.SourceUnit `json:"units,omitempty"`
|
Units []source.SourceUnit `json:"units,omitempty"`
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
Annotations source.ChunkAnnotations `json:"annotations,omitempty"`
|
||||||
|
PlanAnnotations source.ChunkAnnotations `json:"plan_annotations,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type debugSerializedOutput struct {
|
type debugSerializedOutput struct {
|
||||||
@@ -464,13 +466,15 @@ func debugSourceDocumentEnvelope(doc *source.SourceDocument) *debugSourceDocumen
|
|||||||
|
|
||||||
func debugSourceChunkEnvelope(chunk source.Chunk) debugSourceChunk {
|
func debugSourceChunkEnvelope(chunk source.Chunk) debugSourceChunk {
|
||||||
return debugSourceChunk{
|
return debugSourceChunk{
|
||||||
ID: chunk.ID,
|
ID: chunk.ID,
|
||||||
SourceID: chunk.SourceID,
|
SourceID: chunk.SourceID,
|
||||||
Index: chunk.Index,
|
Index: chunk.Index,
|
||||||
Ref: chunk.Ref,
|
Ref: chunk.Ref,
|
||||||
Content: debugContentEnvelope(chunk.Content, chunk.MediaType, chunk.Metadata, nil),
|
Content: debugContentEnvelope(chunk.Content, chunk.MediaType, chunk.Metadata, nil),
|
||||||
Units: cloneSourceUnits(chunk.Units),
|
Units: cloneSourceUnits(chunk.Units),
|
||||||
Metadata: redactSensitiveMap(chunk.Metadata),
|
Metadata: redactSensitiveMap(chunk.Metadata),
|
||||||
|
Annotations: source.CloneChunkAnnotations(chunk.Annotations),
|
||||||
|
PlanAnnotations: source.CloneChunkAnnotations(chunk.PlanAnnotations),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -863,6 +863,8 @@ func cloneSourceChunk(chunk source.Chunk) source.Chunk {
|
|||||||
chunk.Content = append([]byte(nil), chunk.Content...)
|
chunk.Content = append([]byte(nil), chunk.Content...)
|
||||||
chunk.Units = cloneSourceUnits(chunk.Units)
|
chunk.Units = cloneSourceUnits(chunk.Units)
|
||||||
chunk.Metadata = cloneMetadata(chunk.Metadata)
|
chunk.Metadata = cloneMetadata(chunk.Metadata)
|
||||||
|
chunk.Annotations = source.CloneChunkAnnotations(chunk.Annotations)
|
||||||
|
chunk.PlanAnnotations = source.CloneChunkAnnotations(chunk.PlanAnnotations)
|
||||||
return chunk
|
return chunk
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
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
|
lane, typed := state.prepared.resolved, state.prepared.typed
|
||||||
result := extractJobResult{laneIndex: state.index, chunkIndex: chunk.Index}
|
result := extractJobResult{laneIndex: state.index, chunkIndex: chunk.Index}
|
||||||
var accepted erasedExtractArtifact
|
var accepted erasedExtractArtifact
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package pipeline
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"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/contracts"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,6 +52,31 @@ func cloneCheckpointArtifacts(values []CheckpointArtifact) []CheckpointArtifact
|
|||||||
return cloned
|
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) {
|
func TestRunnerContinuesFromFreshAndReusedExtractResults(t *testing.T) {
|
||||||
prepared := preparedAttemptDebugPipeline(t)
|
prepared := preparedAttemptDebugPipeline(t)
|
||||||
extractCalls := 0
|
extractCalls := 0
|
||||||
|
|||||||
Reference in New Issue
Block a user