From 53626570d53da262679ad07be019ec4a1cf92c69 Mon Sep 17 00:00:00 2001 From: iperev Date: Tue, 1 Sep 2026 19:36:23 +0200 Subject: [PATCH 1/5] feat: add bounded requirement source codec --- BACKLOG.md | 1 - .../proofkit-spec-proof-core/overview.md | 7 + .../requirements.v1.json | 13 + .../requirementsourcecodec/admission_test.go | 141 +++++++ .../requirementsourcecodec/benchmark_test.go | 38 ++ .../requirementsourcecodec/codec_fuzz_test.go | 99 +++++ .../diagnostics_test.go | 68 ++++ .../kernel/requirementsourcecodec/document.go | 320 +++++++++++++++ .../field_manifest_test.go | 192 +++++++++ .../requirementsourcecodec/fixture_test.go | 147 +++++++ .../kernel/requirementsourcecodec/format.go | 365 ++++++++++++++++++ .../format_layout_test.go | 117 ++++++ .../requirementsourcecodec/json_index.go | 312 +++++++++++++++ .../limit_manifest_test.go | 119 ++++++ .../kernel/requirementsourcecodec/limits.go | 149 +++++++ .../requirementsourcecodec/limits_test.go | 150 +++++++ .../mutant_manifest_test.go | 140 +++++++ .../package_boundary_test.go | 89 +++++ .../kernel/requirementsourcecodec/parse.go | 142 +++++++ .../requirementsourcecodec/roundtrip_test.go | 101 +++++ .../selection_evaluator_test.go | 170 ++++++++ .../requirementsourcecodec/selection_test.go | 355 +++++++++++++++++ .../kernel/requirementsourcecodec/shape.go | 262 +++++++++++++ .../testdata/codec-field-manifest.v1.json | 152 ++++++++ .../testdata/codec-limit-coefficients.v1.json | 31 ++ .../testdata/codec-mutants.v1.json | 21 + .../testdata/codec-selection.v1.json | 86 +++++ .../kernel/requirementsourcecodec/types.go | 112 ++++++ .../kernel/requirementsourcemodel/limits.go | 4 + .../requirementsourcemodel/normalize.go | 2 +- .../package_boundary_test.go | 2 +- proofkit/requirement-bindings.json | 228 +++++++++++ 32 files changed, 4132 insertions(+), 3 deletions(-) create mode 100644 internal/kernel/requirementsourcecodec/admission_test.go create mode 100644 internal/kernel/requirementsourcecodec/benchmark_test.go create mode 100644 internal/kernel/requirementsourcecodec/codec_fuzz_test.go create mode 100644 internal/kernel/requirementsourcecodec/diagnostics_test.go create mode 100644 internal/kernel/requirementsourcecodec/document.go create mode 100644 internal/kernel/requirementsourcecodec/field_manifest_test.go create mode 100644 internal/kernel/requirementsourcecodec/fixture_test.go create mode 100644 internal/kernel/requirementsourcecodec/format.go create mode 100644 internal/kernel/requirementsourcecodec/format_layout_test.go create mode 100644 internal/kernel/requirementsourcecodec/json_index.go create mode 100644 internal/kernel/requirementsourcecodec/limit_manifest_test.go create mode 100644 internal/kernel/requirementsourcecodec/limits.go create mode 100644 internal/kernel/requirementsourcecodec/limits_test.go create mode 100644 internal/kernel/requirementsourcecodec/mutant_manifest_test.go create mode 100644 internal/kernel/requirementsourcecodec/package_boundary_test.go create mode 100644 internal/kernel/requirementsourcecodec/parse.go create mode 100644 internal/kernel/requirementsourcecodec/roundtrip_test.go create mode 100644 internal/kernel/requirementsourcecodec/selection_evaluator_test.go create mode 100644 internal/kernel/requirementsourcecodec/selection_test.go create mode 100644 internal/kernel/requirementsourcecodec/shape.go create mode 100644 internal/kernel/requirementsourcecodec/testdata/codec-field-manifest.v1.json create mode 100644 internal/kernel/requirementsourcecodec/testdata/codec-limit-coefficients.v1.json create mode 100644 internal/kernel/requirementsourcecodec/testdata/codec-mutants.v1.json create mode 100644 internal/kernel/requirementsourcecodec/testdata/codec-selection.v1.json create mode 100644 internal/kernel/requirementsourcecodec/types.go diff --git a/BACKLOG.md b/BACKLOG.md index b739c94..ad9258e 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -48,7 +48,6 @@ records, generated release manifests, or the owning docs named above. | Status | ID | Scope | Completion condition | |---|---|---|---| -| NEXT | SOURCE-CODEC-01 | Select at most one compact source codec without creating dual authority. | After `SOURCE-MODEL-01`, one versioned experiment manifest freezes disjoint role sets: the flat-v1 baseline control, grouped-model ablations, and exactly complete grouped-JSON plus at most one complete restricted-DSL codec candidate over the same model. Only codec candidates can win the predeclared replacement relation; controls and ablations measure causality and cannot become production grammars. A newly discovered candidate requires a new manifest version and complete experiment. A frozen corpus and strict `Replace(candidate, grouped-json)` predicate cover grammar completeness, safety, semantic parity, diagnostics, canonical bytes, review accuracy, token cost, diff amplification, parse/format cost, and unknowns. Every metric is classified exactly once by a versioned registry with role, direction, baseline pair, aggregation, material threshold, primary decision requirement, and missing-observation semantics; duplicate or unclassified metrics fail admission, hard constraints cannot trade off, report-only metrics cannot decide replacement, promised byte/token reductions must be materially better, and bounded diff/parse costs must be noninferior. If grouped JSON fails its hard gate, retain the current flat v1 source and perform no v2 cutover; otherwise select the restricted text candidate only when it is the unique strict replacement, while a tie, unknown, incomparability, or non-material improvement selects grouped JSON. The losing parser and formatter are deleted before experiment closeout, and production admits exactly one grammar. | | BLOCKED | SOURCE-CUTOVER-01 | Migrate self-hosted requirement sources only after one codec, the typed v2 model, nested structural contracts, and the complete evidence counterfeit corpus pass their gates. | The `REQ-PROOFKIT-QUALITY-010` execution-backed command-oracle closure and `SCHEMA-01` are complete; a digest-bound clause ledger proves representation-only equality or owner-reviewed semantic decomposition for every legacy requirement; all bindings/scenarios/contracts/context/diff/graph/browser owners cut over atomically; v1 admission and the losing codec are removed; and active-v1 inventory is zero. | | BLOCKED | SCHEMA-01 | Replace root-shape-only public contracts with one independent complete nested structural-contract owner. | A versioned schema owner covers nested fields, variants, cardinalities, bounds, enums, defaults, duplicate and unknown-field policy, and cross-field constraints; generated artifacts pass parity against an independently authored completeness manifest and mutant corpus without becoming semantic or policy authority. | | BLOCKED | SOURCE-PILOT-01 | Validate the selected source-v2 model and agent routing against heterogeneous external repositories without mutating them. | At least two independent repository classes complete no-push dual runs whose frozen inputs compare incumbent and candidate mapping, diagnostics, token cost, authoring accuracy, proof-route gaps, and rollback; unresolved parity or authority gaps keep incumbent owners active. | diff --git a/docs/specs/proofkit-spec-proof-core/overview.md b/docs/specs/proofkit-spec-proof-core/overview.md index e8dfda1..14f3edd 100644 --- a/docs/specs/proofkit-spec-proof-core/overview.md +++ b/docs/specs/proofkit-spec-proof-core/overview.md @@ -154,6 +154,13 @@ execution receipts, and merge policy. package, field, representation, variant, and positive/negative relation coverage without attributing correlated edits to independent field causality, selecting a codec, or changing a public source boundary. +- `REQ-PROOFKIT-SPEC-025`: a versioned disjoint-role experiment selects one + private v2 source grammar, grouped JSON with an entity-local hybrid layout; + its strict bounded codec delegates meaning to the representation-neutral + model, preserves every projection and metadata-presence state, emits + deterministic nondisclosing source locations, and is closed by independent + field, limit, selection, mutant, round-trip, fuzz-seed, and package-boundary + witnesses without migrating current sources or exposing a new public CLI. ## Non-Claims diff --git a/docs/specs/proofkit-spec-proof-core/requirements.v1.json b/docs/specs/proofkit-spec-proof-core/requirements.v1.json index 420552c..155f830 100644 --- a/docs/specs/proofkit-spec-proof-core/requirements.v1.json +++ b/docs/specs/proofkit-spec-proof-core/requirements.v1.json @@ -568,6 +568,19 @@ "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, "deferral": null, "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "ownerId": "proofkit.spec-proof-core", + "invariant": "A private requirement-source codec selection admits exactly one v2 persisted grammar, grouped JSON with the frozen entity-local hybrid layout, after a versioned disjoint-role screen rejects compact and pretty JSON layouts for edit-locality failure and admits no YAML, TOML, or restricted-text parser authority. The selected codec maps bounded UTF-8 bytes through duplicate-, case-, unknown-, missing-, null-, integer-, and Unicode-scalar-closed structural admission into exactly one requirementsourcemodel.NormalizeWithLimits call; emits immutable atomic, authoring-layout, and typed-reference projections plus a deterministic source map; formats only a model re-admitted by the semantic owner; preserves metadata absence, present-null deferral, present-record deferral, ordered actions, and sorted dynamic maps; emits fixed-order entity-local canonical JSON with exact unsafe-scalar escaping and one final line feed; and applies raw-byte, UTF-8, lexical-token, nesting, representation-cardinality, model-resource, model-semantic, and canonical-output failure precedence without disclosing caller text. Independently authored field, limit-coefficient, selection, and executable mutant manifests close the DTO, structural schema, resource formula, candidate roles, decision, diagnostic paths, and losing-grammar boundary; round-trip, idempotence, source-span replay, exact-limit, fuzz-seed, and package-boundary witnesses reject a second production grammar or semantic owner.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-025"], + "nonClaims": ["This private codec does not migrate or rewrite current requirement sources, expose a public source extension or CLI command, retain a normalized mirror, authenticate requirement meaning or derivation provenance, generalize the frozen formatter screen beyond its corpus, execute native witnesses, approve merge or release, or establish rollout or production readiness."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} } ], "nonClaims": [ diff --git a/internal/kernel/requirementsourcecodec/admission_test.go b/internal/kernel/requirementsourcecodec/admission_test.go new file mode 100644 index 0000000..00fc69f --- /dev/null +++ b/internal/kernel/requirementsourcecodec/admission_test.go @@ -0,0 +1,141 @@ +package requirementsourcecodec + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +func TestParseDiagnosticsDoNotDiscloseCallerTextOrDynamicKeys(t *testing.T) { + const sentinel = "ghp_0123456789abcdefghijklmnopqrstuvwxyz" + tests := [][]byte{ + mutateRoot(t, mustPayload(t), func(root map[string]any) { root[sentinel] = true }), + mutateRoot(t, mustPayload(t), func(root map[string]any) { + scenarios := root["scenarios"].([]any) + examples := scenarios[0].(map[string]any)["examples"].([]any) + examples[0].(map[string]any)["values"].(map[string]any)[sentinel] = true + }), + mutateRoot(t, mustPayload(t), func(root map[string]any) { + definitions := root["nonClaimDefinitions"].([]any) + definitions[0].(map[string]any)["statement"] = "token=" + sentinel + }), + } + for index, payload := range tests { + _, err := Parse(payload) + if err == nil { + t.Fatalf("case %d: Parse() unexpectedly passed", index) + } + diagnostic, ok := err.(*Error) + if !ok { + t.Fatalf("case %d: error type = %T", index, err) + } + if strings.Contains(err.Error(), sentinel) || strings.Contains(diagnostic.Diagnostic().Path, sentinel) { + t.Fatalf("case %d: diagnostic disclosed caller text: %v", index, err) + } + } +} + +func TestParseRejectsMalformedJSON(t *testing.T) { + for _, payload := range [][]byte{ + {}, + []byte(`{"schemaVersion":2`), + []byte(`[]`), + append(mustPayload(t), 0), + } { + _, err := Parse(payload) + if err == nil { + t.Fatalf("Parse(%q) unexpectedly passed", payload) + } + } +} + +func TestParseAcceptsValidUnicodeSurrogatePairLosslessly(t *testing.T) { + draft := testDraft() + draft.NonClaimDefinitions[0].Statement = "The codec preserves \U0001f642 text." + canonical := mustFormatDraft(t, draft) + escaped := bytes.Replace(canonical, []byte("\U0001f642"), []byte(`\ud83d\ude42`), 1) + result, err := Parse(escaped) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + expected, err := requirementsourcemodel.Normalize(draft) + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + if !projectionsEqual(result.Model, expected) { + t.Fatal("valid surrogate pair changed semantic value") + } +} + +func TestFormatRejectsZeroModel(t *testing.T) { + _, err := Format(requirementsourcemodel.Model{}) + if ErrorCode(err) != "invalid_model" { + t.Fatalf("Format(zero) error = %v", err) + } +} + +func mustPayload(t *testing.T) []byte { + t.Helper() + payload, err := Format(mustModel(t)) + if err != nil { + t.Fatalf("Format() error = %v", err) + } + return payload +} + +func mutateRoot(t *testing.T, payload []byte, mutate func(map[string]any)) []byte { + t.Helper() + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.UseNumber() + var root map[string]any + if err := decoder.Decode(&root); err != nil { + t.Fatalf("decode fixture: %v", err) + } + mutate(root) + result, err := json.Marshal(root) + if err != nil { + t.Fatalf("marshal mutant: %v", err) + } + return result +} + +func duplicateRootField(t *testing.T, payload []byte, field string) []byte { + t.Helper() + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.UseNumber() + var root map[string]any + if err := decoder.Decode(&root); err != nil { + t.Fatalf("decode fixture: %v", err) + } + value, err := json.Marshal(root[field]) + if err != nil { + t.Fatalf("marshal field: %v", err) + } + prefix := []byte("{") + duplicate := append([]byte(`{"`+field+`":`), value...) + duplicate = append(duplicate, ',') + if !bytes.HasPrefix(payload, prefix) { + t.Fatal("fixture is not a JSON object") + } + return append(duplicate, payload[1:]...) +} + +func assertDiagnostic(t *testing.T, err error, code string, path string) { + t.Helper() + if err == nil { + t.Fatalf("expected %s at %s", code, path) + } + if ErrorCode(err) != code { + t.Fatalf("ErrorCode() = %q, want %q; error = %v", ErrorCode(err), code, err) + } + typed, ok := err.(*Error) + if !ok { + t.Fatalf("error type = %T", err) + } + if diagnostic := typed.Diagnostic(); diagnostic.Path != path { + t.Fatalf("Diagnostic().Path = %q, want %q", diagnostic.Path, path) + } +} diff --git a/internal/kernel/requirementsourcecodec/benchmark_test.go b/internal/kernel/requirementsourcecodec/benchmark_test.go new file mode 100644 index 0000000..4d4b541 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/benchmark_test.go @@ -0,0 +1,38 @@ +package requirementsourcecodec + +import ( + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +func BenchmarkParseCanonicalSource(b *testing.B) { + model, err := requirementsourcemodel.Normalize(testDraft()) + if err != nil { + b.Fatal(err) + } + payload, err := Format(model) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.SetBytes(int64(len(payload))) + for b.Loop() { + if _, err := Parse(payload); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkFormatCanonicalSource(b *testing.B) { + model, err := requirementsourcemodel.Normalize(testDraft()) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + for b.Loop() { + if _, err := Format(model); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/kernel/requirementsourcecodec/codec_fuzz_test.go b/internal/kernel/requirementsourcecodec/codec_fuzz_test.go new file mode 100644 index 0000000..13414f5 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/codec_fuzz_test.go @@ -0,0 +1,99 @@ +package requirementsourcecodec + +import ( + "bytes" + "reflect" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +func FuzzParseCanonicalRoundTrip(f *testing.F) { + modelLimits := compactTestModelLimits() + codecLimits := pairedCodecLimits(f, modelLimits) + f.Add([]byte(`{}`)) + f.Add([]byte(`{"schemaVersion":2,"kind":"proofkit.requirement-source"}`)) + model, err := requirementsourcemodel.NormalizeWithLimits(testDraft(), modelLimits) + if err == nil { + payload, formatErr := FormatWithLimits(model, codecLimits, modelLimits) + if formatErr == nil { + f.Add(payload) + } + } + f.Fuzz(func(t *testing.T, source []byte) { + assertCodecFuzzProperties(t, source, codecLimits, modelLimits) + }) +} + +func TestFuzzSeedCorpusRoundTrips(t *testing.T) { + modelLimits := compactTestModelLimits() + codecLimits := pairedCodecLimits(t, modelLimits) + seeds := [][]byte{ + []byte(`{}`), + []byte(`{"schemaVersion":2,"kind":"proofkit.requirement-source"}`), + mustPayload(t), + } + for _, seed := range seeds { + assertCodecFuzzProperties(t, seed, codecLimits, modelLimits) + } +} + +func assertCodecFuzzProperties(t testing.TB, source []byte, codecLimits Limits, modelLimits requirementsourcemodel.Limits) { + t.Helper() + first, firstErr := ParseWithLimits(source, codecLimits, modelLimits) + second, secondErr := ParseWithLimits(source, codecLimits, modelLimits) + if ErrorCode(firstErr) != ErrorCode(secondErr) { + t.Fatalf("nondeterministic error code: %q != %q", ErrorCode(firstErr), ErrorCode(secondErr)) + } + if firstErr != nil || secondErr != nil { + if firstErr == nil || secondErr == nil || firstErr.Error() != secondErr.Error() { + t.Fatalf("nondeterministic error: %v != %v", firstErr, secondErr) + } + return + } + if !projectionsEqual(first.Model, second.Model) { + t.Fatal("same bytes produced different models") + } + canonical, err := FormatWithLimits(first.Model, codecLimits, modelLimits) + if err != nil { + t.Fatalf("Format(admitted model) error = %v", err) + } + reparsed, err := ParseWithLimits(canonical, codecLimits, modelLimits) + if err != nil { + t.Fatalf("Parse(canonical) error = %v", err) + } + if !projectionsEqual(first.Model, reparsed.Model) { + t.Fatal("canonical round trip changed model") + } + secondCanonical, err := FormatWithLimits(reparsed.Model, codecLimits, modelLimits) + if err != nil || !bytes.Equal(canonical, secondCanonical) { + t.Fatal("canonical formatting is not idempotent") + } + assertFuzzSourceMap(t, source, first.SourceMap) +} + +func assertFuzzSourceMap(t testing.TB, source []byte, sourceMap SourceMap) { + t.Helper() + pointers := sourceMap.Pointers() + if !sortStringsEqual(pointers, append([]string(nil), pointers...)) { + t.Fatal("source-map pointers are not sorted") + } + for _, pointer := range pointers { + location, exists := sourceMap.Location(pointer) + if !exists || !validFuzzSpan(location.ValueSpan, len(source)) { + t.Fatalf("invalid value span for %q: %#v", pointer, location.ValueSpan) + } + if location.KeySpan != nil && !validFuzzSpan(*location.KeySpan, len(source)) { + t.Fatalf("invalid key span for %q: %#v", pointer, *location.KeySpan) + } + } +} + +func validFuzzSpan(span ByteSpan, length int) bool { + return span.Start >= 0 && span.Start <= span.End && span.End <= int64(length) +} + +func sortStringsEqual(actual []string, clone []string) bool { + sortStrings(clone) + return reflect.DeepEqual(actual, clone) +} diff --git a/internal/kernel/requirementsourcecodec/diagnostics_test.go b/internal/kernel/requirementsourcecodec/diagnostics_test.go new file mode 100644 index 0000000..9901c12 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/diagnostics_test.go @@ -0,0 +1,68 @@ +package requirementsourcecodec + +import ( + "bytes" + "testing" +) + +func TestInvalidUTF8UsesByteOnlyCoordinates(t *testing.T) { + payload := []byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'} + _, err := Parse(payload) + assertDiagnostic(t, err, "invalid_utf8", "") + diagnostic := err.(*Error).Diagnostic() + if diagnostic.CoordinateState != "byte_only" || diagnostic.Start != nil || diagnostic.End != nil { + t.Fatalf("invalid UTF-8 coordinates = %#v", diagnostic) + } + if diagnostic.Span != (ByteSpan{Start: 6, End: 7}) { + t.Fatalf("invalid UTF-8 span = %#v", diagnostic.Span) + } +} + +func TestValidUnicodeDiagnosticsUseScalarColumns(t *testing.T) { + payload := []byte("{\n \"kind\": \"proofkit.requirement-source\",\n \"schemaVersion\": 2,\n \"sourceId\": \"\u03bb\",\n \"extra\": true\n}") + _, err := Parse(payload) + assertDiagnostic(t, err, "unknown_field", "/") + diagnostic := err.(*Error).Diagnostic() + if diagnostic.CoordinateState != "scalar" || diagnostic.Start == nil || diagnostic.End == nil { + t.Fatalf("valid UTF-8 coordinates = %#v", diagnostic) + } + if diagnostic.Start.Line != 5 || diagnostic.Start.ScalarColumn != 3 { + t.Fatalf("unknown-field start = %#v", diagnostic.Start) + } +} + +func TestShapeDiagnosticSelectionFollowsSourceOrder(t *testing.T) { + firstUnknown := mutateRoot(t, mustPayload(t), func(root map[string]any) { + root["zUnknown"] = true + root["aUnknown"] = true + }) + firstUnknown = moveFieldFirst(t, firstUnknown, "zUnknown") + for run := 0; run < 20; run++ { + _, err := Parse(firstUnknown) + assertDiagnostic(t, err, "unknown_field", "/") + span := err.(*Error).Diagnostic().Span + if !bytes.Equal(firstUnknown[span.Start:span.End], []byte(`"zUnknown"`)) { + t.Fatalf("run %d selected %q", run, firstUnknown[span.Start:span.End]) + } + } +} + +func moveFieldFirst(t *testing.T, payload []byte, field string) []byte { + t.Helper() + needle := []byte(`"` + field + `":true`) + index := bytes.Index(payload, needle) + if index < 0 { + t.Fatalf("field %q not found", field) + } + end := index + len(needle) + if end < len(payload) && payload[end] == ',' { + end++ + } else if index > 0 && payload[index-1] == ',' { + index-- + } + fieldBytes := append([]byte(nil), payload[index:end]...) + fieldBytes = bytes.Trim(fieldBytes, ",") + remainder := append([]byte(nil), payload[:index]...) + remainder = append(remainder, payload[end:]...) + return append(append(append([]byte{'{'}, fieldBytes...), ','), remainder[1:]...) +} diff --git a/internal/kernel/requirementsourcecodec/document.go b/internal/kernel/requirementsourcecodec/document.go new file mode 100644 index 0000000..375d928 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/document.go @@ -0,0 +1,320 @@ +package requirementsourcecodec + +import ( + "bytes" + "encoding/json" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +type document struct { + SchemaVersion int `json:"schemaVersion"` + Kind string `json:"kind"` + SourceID string `json:"sourceId"` + SpecPackagePath string `json:"specPackagePath"` + SourceNonClaimRefs []string `json:"sourceNonClaimRefs"` + NonClaimDefinitions []nonClaimDefinition `json:"nonClaimDefinitions"` + Vocabulary []vocabularyTerm `json:"vocabulary"` + Derivations []derivation `json:"derivations"` + Profiles []profile `json:"profiles"` + Groups []group `json:"groups"` + Scenarios []scenario `json:"scenarios"` +} + +type nonClaimDefinition struct { + NonClaimID string `json:"nonClaimId"` + Statement string `json:"statement"` +} + +type vocabularyTerm struct { + TermID string `json:"termId"` + Kind string `json:"kind"` + Label string `json:"label"` + Definition string `json:"definition"` +} + +type derivation struct { + DerivationID string `json:"derivationId"` + SourceKind string `json:"sourceKind"` + SourceRef gitBlobRef `json:"sourceRef"` + Selector byteRange `json:"selector"` + RequirementIDs []string `json:"requirementIds"` + NonClaimRefs []string `json:"nonClaimRefs"` +} + +type gitBlobRef struct { + ObjectFormat string `json:"objectFormat"` + CommitOID string `json:"commitOid"` + Path string `json:"path"` + SHA256 string `json:"sha256"` +} + +type byteRange struct { + Start int64 `json:"start"` + End int64 `json:"end"` +} + +type profile struct { + ProfileID string `json:"profileId"` + Fields metadataFields `json:"fields"` +} + +type group struct { + GroupID string `json:"groupId"` + ProfileID string `json:"profileId"` + StatementStem string `json:"statementStem"` + SharedPremises []string `json:"sharedPremises"` + Members []member `json:"members"` +} + +type member struct { + RequirementID string `json:"requirementId"` + StatementCompletion string `json:"statementCompletion"` + Fields metadataFields `json:"fields"` +} + +type metadataFields struct { + OwnerID *string `json:"ownerId,omitempty"` + ClaimLevel *string `json:"claimLevel,omitempty"` + RiskClass *string `json:"riskClass,omitempty"` + NonClaimRefs *[]string `json:"nonClaimRefs,omitempty"` + Lifecycle *lifecycle `json:"lifecycle,omitempty"` + Deferral json.RawMessage `json:"deferral,omitempty"` + UpdatePolicy *updatePolicy `json:"updatePolicy,omitempty"` +} + +type lifecycle struct { + State string `json:"state"` + ReplacementRequirementIDs []string `json:"replacementRequirementIds"` + EvidenceRefs []string `json:"evidenceRefs"` +} + +type deferral struct { + OwnerID string `json:"ownerId"` + RiskAcceptedBy string `json:"riskAcceptedBy"` + ReviewCondition string `json:"reviewCondition"` + ExpiryRef string `json:"expiryRef"` + MergePolicy string `json:"mergePolicy"` + EvidenceRefs []string `json:"evidenceRefs"` +} + +type updatePolicy struct { + ReviewOwnerID string `json:"reviewOwnerId"` + RequiresImpactDeclaration bool `json:"requiresImpactDeclaration"` + RequiresProofBindingReview bool `json:"requiresProofBindingReview"` +} + +type scenario struct { + ScenarioID string `json:"scenarioId"` + RequirementIDs []string `json:"requirementIds"` + Parameters []string `json:"parameters"` + Preconditions []string `json:"preconditions"` + ActionSequence []string `json:"actionSequence"` + ExpectedObservations []string `json:"expectedObservations"` + ForbiddenObservations []string `json:"forbiddenObservations"` + Examples []example `json:"examples"` + VocabularyRefs []string `json:"vocabularyRefs"` + NonClaimRefs []string `json:"nonClaimRefs"` +} + +type example struct { + ExampleID string `json:"exampleId"` + Values map[string]string `json:"values"` +} + +func draftFromDocument(value document) (requirementsourcemodel.Draft, error) { + definitions := make([]requirementsourcemodel.NonClaimDefinition, len(value.NonClaimDefinitions)) + for index, item := range value.NonClaimDefinitions { + definitions[index] = requirementsourcemodel.NonClaimDefinition{NonClaimID: item.NonClaimID, Statement: item.Statement} + } + vocabulary := make([]requirementsourcemodel.VocabularyTerm, len(value.Vocabulary)) + for index, item := range value.Vocabulary { + vocabulary[index] = requirementsourcemodel.VocabularyTerm{TermID: item.TermID, Kind: requirementsourcemodel.TermKind(item.Kind), Label: item.Label, Definition: item.Definition} + } + derivations := make([]requirementsourcemodel.Derivation, len(value.Derivations)) + for index, item := range value.Derivations { + derivations[index] = requirementsourcemodel.Derivation{ + DerivationID: item.DerivationID, SourceKind: requirementsourcemodel.SourceKind(item.SourceKind), + SourceRef: requirementsourcemodel.GitBlobRef{ObjectFormat: requirementsourcemodel.ObjectFormat(item.SourceRef.ObjectFormat), CommitOID: item.SourceRef.CommitOID, Path: item.SourceRef.Path, SHA256: item.SourceRef.SHA256}, + Selector: requirementsourcemodel.ByteRange{Start: item.Selector.Start, End: item.Selector.End}, + RequirementIDs: cloneStrings(item.RequirementIDs), NonClaimRefs: cloneStrings(item.NonClaimRefs), + } + } + profiles := make([]requirementsourcemodel.Profile, len(value.Profiles)) + for index, item := range value.Profiles { + fields, err := modelMetadata(item.Fields) + if err != nil { + return requirementsourcemodel.Draft{}, err + } + profiles[index] = requirementsourcemodel.Profile{ProfileID: item.ProfileID, Fields: fields} + } + groups := make([]requirementsourcemodel.Group, len(value.Groups)) + for groupIndex, item := range value.Groups { + members := make([]requirementsourcemodel.Member, len(item.Members)) + for memberIndex, memberValue := range item.Members { + fields, err := modelMetadata(memberValue.Fields) + if err != nil { + return requirementsourcemodel.Draft{}, err + } + members[memberIndex] = requirementsourcemodel.Member{RequirementID: memberValue.RequirementID, StatementCompletion: memberValue.StatementCompletion, Fields: fields} + } + groups[groupIndex] = requirementsourcemodel.Group{GroupID: item.GroupID, ProfileID: item.ProfileID, StatementStem: item.StatementStem, SharedPremises: cloneStrings(item.SharedPremises), Members: members} + } + scenarios := make([]requirementsourcemodel.Scenario, len(value.Scenarios)) + for index, item := range value.Scenarios { + examples := make([]requirementsourcemodel.Example, len(item.Examples)) + for exampleIndex, exampleValue := range item.Examples { + values := make(map[string]requirementsourcemodel.ScenarioValue, len(exampleValue.Values)) + for key, scalar := range exampleValue.Values { + values[key] = requirementsourcemodel.ScenarioValue(scalar) + } + examples[exampleIndex] = requirementsourcemodel.Example{ExampleID: exampleValue.ExampleID, Values: values} + } + scenarios[index] = requirementsourcemodel.Scenario{ + ScenarioID: item.ScenarioID, RequirementIDs: cloneStrings(item.RequirementIDs), Parameters: cloneStrings(item.Parameters), + Preconditions: cloneStrings(item.Preconditions), ActionSequence: cloneStrings(item.ActionSequence), ExpectedObservations: cloneStrings(item.ExpectedObservations), + ForbiddenObservations: cloneStrings(item.ForbiddenObservations), Examples: examples, VocabularyRefs: cloneStrings(item.VocabularyRefs), NonClaimRefs: cloneStrings(item.NonClaimRefs), + } + } + return requirementsourcemodel.Draft{ + SourceID: value.SourceID, SpecPackagePath: value.SpecPackagePath, SourceNonClaimRefs: cloneStrings(value.SourceNonClaimRefs), + NonClaimDefinitions: definitions, Vocabulary: vocabulary, Derivations: derivations, Profiles: profiles, Groups: groups, Scenarios: scenarios, + }, nil +} + +func modelMetadata(value metadataFields) (requirementsourcemodel.MetadataFields, error) { + result := requirementsourcemodel.MetadataFields{} + if value.OwnerID != nil { + result.OwnerID = requirementsourcemodel.Own(*value.OwnerID) + } + if value.ClaimLevel != nil { + result.ClaimLevel = requirementsourcemodel.Own(requirementsourcemodel.ClaimLevel(*value.ClaimLevel)) + } + if value.RiskClass != nil { + result.RiskClass = requirementsourcemodel.Own(requirementsourcemodel.RiskClass(*value.RiskClass)) + } + if value.NonClaimRefs != nil { + result.NonClaimRefs = requirementsourcemodel.Own(cloneStrings(*value.NonClaimRefs)) + } + if value.Lifecycle != nil { + result.Lifecycle = requirementsourcemodel.Own(requirementsourcemodel.Lifecycle{State: requirementsourcemodel.LifecycleState(value.Lifecycle.State), ReplacementRequirementIDs: cloneStrings(value.Lifecycle.ReplacementRequirementIDs), EvidenceRefs: cloneStrings(value.Lifecycle.EvidenceRefs)}) + } + if value.Deferral != nil { + if bytes.Equal(bytes.TrimSpace(value.Deferral), []byte("null")) { + result.Deferral = requirementsourcemodel.Own[*requirementsourcemodel.Deferral](nil) + } else { + var item deferral + if err := json.Unmarshal(value.Deferral, &item); err != nil { + return requirementsourcemodel.MetadataFields{}, err + } + result.Deferral = requirementsourcemodel.Own(&requirementsourcemodel.Deferral{OwnerID: item.OwnerID, RiskAcceptedBy: item.RiskAcceptedBy, ReviewCondition: item.ReviewCondition, ExpiryRef: item.ExpiryRef, MergePolicy: item.MergePolicy, EvidenceRefs: cloneStrings(item.EvidenceRefs)}) + } + } + if value.UpdatePolicy != nil { + result.UpdatePolicy = requirementsourcemodel.Own(requirementsourcemodel.UpdatePolicy{ReviewOwnerID: value.UpdatePolicy.ReviewOwnerID, RequiresImpactDeclaration: value.UpdatePolicy.RequiresImpactDeclaration, RequiresProofBindingReview: value.UpdatePolicy.RequiresProofBindingReview}) + } + return result, nil +} + +func documentFromModel(model requirementsourcemodel.Model) (document, error) { + atomic := model.Atomic() + layout := model.Layout() + references := model.References() + value := document{ + SchemaVersion: SchemaVersion, Kind: DocumentKind, SourceID: atomic.SourceID, SpecPackagePath: atomic.SpecPackagePath, + SourceNonClaimRefs: nonNilStrings(atomic.SourceNonClaimRefs), NonClaimDefinitions: make([]nonClaimDefinition, len(atomic.NonClaimDefinitions)), + Vocabulary: make([]vocabularyTerm, len(atomic.Vocabulary)), Derivations: make([]derivation, len(references.Derivations)), + Profiles: make([]profile, len(layout.Profiles)), Groups: make([]group, len(layout.Groups)), Scenarios: make([]scenario, len(atomic.Scenarios)), + } + for index, item := range atomic.NonClaimDefinitions { + value.NonClaimDefinitions[index] = nonClaimDefinition{NonClaimID: item.NonClaimID, Statement: item.Statement} + } + for index, item := range atomic.Vocabulary { + value.Vocabulary[index] = vocabularyTerm{TermID: item.TermID, Kind: string(item.Kind), Label: item.Label, Definition: item.Definition} + } + for index, item := range references.Derivations { + value.Derivations[index] = derivation{DerivationID: item.DerivationID, SourceKind: string(item.SourceKind), SourceRef: gitBlobRef{ObjectFormat: string(item.SourceRef.ObjectFormat), CommitOID: item.SourceRef.CommitOID, Path: item.SourceRef.Path, SHA256: item.SourceRef.SHA256}, Selector: byteRange{Start: item.Selector.Start, End: item.Selector.End}, RequirementIDs: nonNilStrings(item.RequirementIDs), NonClaimRefs: nonNilStrings(item.NonClaimRefs)} + } + for index, item := range layout.Profiles { + fields, err := wireMetadata(item.Fields) + if err != nil { + return document{}, err + } + value.Profiles[index] = profile{ProfileID: item.ProfileID, Fields: fields} + } + for groupIndex, item := range layout.Groups { + members := make([]member, len(item.Members)) + for memberIndex, memberValue := range item.Members { + fields, err := wireMetadata(memberValue.Fields) + if err != nil { + return document{}, err + } + members[memberIndex] = member{RequirementID: memberValue.RequirementID, StatementCompletion: memberValue.StatementCompletion, Fields: fields} + } + value.Groups[groupIndex] = group{GroupID: item.GroupID, ProfileID: item.ProfileID, StatementStem: item.StatementStem, SharedPremises: nonNilStrings(item.SharedPremises), Members: members} + } + for index, item := range atomic.Scenarios { + examples := make([]example, len(item.Examples)) + for exampleIndex, exampleValue := range item.Examples { + values := make(map[string]string, len(exampleValue.Values)) + for key, scalar := range exampleValue.Values { + values[key] = string(scalar) + } + examples[exampleIndex] = example{ExampleID: exampleValue.ExampleID, Values: values} + } + value.Scenarios[index] = scenario{ScenarioID: item.ScenarioID, RequirementIDs: nonNilStrings(item.RequirementIDs), Parameters: nonNilStrings(item.Parameters), Preconditions: nonNilStrings(item.Preconditions), ActionSequence: nonNilStrings(item.ActionSequence), ExpectedObservations: nonNilStrings(item.ExpectedObservations), ForbiddenObservations: nonNilStrings(item.ForbiddenObservations), Examples: examples, VocabularyRefs: nonNilStrings(item.VocabularyRefs), NonClaimRefs: nonNilStrings(item.NonClaimRefs)} + } + return value, nil +} + +func wireMetadata(value requirementsourcemodel.MetadataFields) (metadataFields, error) { + result := metadataFields{} + if value.OwnerID.Present { + item := value.OwnerID.Value + result.OwnerID = &item + } + if value.ClaimLevel.Present { + item := string(value.ClaimLevel.Value) + result.ClaimLevel = &item + } + if value.RiskClass.Present { + item := string(value.RiskClass.Value) + result.RiskClass = &item + } + if value.NonClaimRefs.Present { + item := nonNilStrings(value.NonClaimRefs.Value) + result.NonClaimRefs = &item + } + if value.Lifecycle.Present { + result.Lifecycle = &lifecycle{State: string(value.Lifecycle.Value.State), ReplacementRequirementIDs: nonNilStrings(value.Lifecycle.Value.ReplacementRequirementIDs), EvidenceRefs: nonNilStrings(value.Lifecycle.Value.EvidenceRefs)} + } + if value.Deferral.Present { + if value.Deferral.Value == nil { + result.Deferral = json.RawMessage("null") + } else { + payload, err := json.Marshal(deferral{OwnerID: value.Deferral.Value.OwnerID, RiskAcceptedBy: value.Deferral.Value.RiskAcceptedBy, ReviewCondition: value.Deferral.Value.ReviewCondition, ExpiryRef: value.Deferral.Value.ExpiryRef, MergePolicy: value.Deferral.Value.MergePolicy, EvidenceRefs: nonNilStrings(value.Deferral.Value.EvidenceRefs)}) + if err != nil { + return metadataFields{}, err + } + result.Deferral = payload + } + } + if value.UpdatePolicy.Present { + result.UpdatePolicy = &updatePolicy{ReviewOwnerID: value.UpdatePolicy.Value.ReviewOwnerID, RequiresImpactDeclaration: value.UpdatePolicy.Value.RequiresImpactDeclaration, RequiresProofBindingReview: value.UpdatePolicy.Value.RequiresProofBindingReview} + } + return result, nil +} + +func cloneStrings(values []string) []string { + if values == nil { + return nil + } + return append([]string(nil), values...) +} + +func nonNilStrings(values []string) []string { + if len(values) == 0 { + return []string{} + } + return append([]string(nil), values...) +} diff --git a/internal/kernel/requirementsourcecodec/field_manifest_test.go b/internal/kernel/requirementsourcecodec/field_manifest_test.go new file mode 100644 index 0000000..0ad3e32 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/field_manifest_test.go @@ -0,0 +1,192 @@ +package requirementsourcecodec + +import ( + "bytes" + "encoding/json" + "os" + "reflect" + "sort" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +type codecFieldManifest struct { + SchemaVersion int `json:"schemaVersion"` + Kind string `json:"kind"` + RootRecordID string `json:"rootRecordId"` + Records []manifestRecord `json:"records"` +} + +type manifestRecord struct { + RecordID string `json:"recordId"` + Fields []manifestField `json:"fields"` +} + +type manifestField struct { + Name string `json:"name"` + Type string `json:"type"` + Required bool `json:"required"` + Nullable bool `json:"nullable"` + Constant string `json:"constant,omitempty"` +} + +func TestFieldManifestMatchesWireDTOAndClosedShape(t *testing.T) { + manifest := readCodecFieldManifest(t) + if manifest.SchemaVersion != 1 || manifest.Kind != "proofkit.requirement-source-codec-field-manifest" || manifest.RootRecordID != "document" { + t.Fatalf("manifest identity = %#v", manifest) + } + actual := wireRecordManifest(t) + if !reflect.DeepEqual(manifest.Records, actual) { + t.Fatalf("manifest records do not match wire DTO\nmanifest: %#v\nwire: %#v", manifest.Records, actual) + } + byID := make(map[string]manifestRecord, len(manifest.Records)) + for _, record := range manifest.Records { + byID[record.RecordID] = record + } + seen := map[string]struct{}{} + assertShapeRecord(t, manifest.RootRecordID, documentShape(requirementsourcemodel.DefaultLimits()), byID, seen) + if len(seen) != len(byID) { + t.Fatalf("shape reached %d/%d manifest records", len(seen), len(byID)) + } +} + +func readCodecFieldManifest(t *testing.T) codecFieldManifest { + t.Helper() + payload, err := os.ReadFile("testdata/codec-field-manifest.v1.json") + if err != nil { + t.Fatal(err) + } + manifest, err := admission.DecodeTypedJSON[codecFieldManifest](bytes.NewReader(payload), int64(len(payload))) + if err != nil { + t.Fatal(err) + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + var strict codecFieldManifest + if err := decoder.Decode(&strict); err != nil { + t.Fatal(err) + } + return manifest +} + +func wireRecordManifest(t *testing.T) []manifestRecord { + t.Helper() + recordTypes := []reflect.Type{ + reflect.TypeOf(byteRange{}), reflect.TypeOf(deferral{}), reflect.TypeOf(derivation{}), + reflect.TypeOf(document{}), reflect.TypeOf(example{}), reflect.TypeOf(gitBlobRef{}), + reflect.TypeOf(group{}), reflect.TypeOf(lifecycle{}), reflect.TypeOf(member{}), + reflect.TypeOf(metadataFields{}), reflect.TypeOf(nonClaimDefinition{}), reflect.TypeOf(profile{}), + reflect.TypeOf(scenario{}), reflect.TypeOf(updatePolicy{}), reflect.TypeOf(vocabularyTerm{}), + } + records := make([]manifestRecord, len(recordTypes)) + for recordIndex, recordType := range recordTypes { + fields := make([]manifestField, 0, recordType.NumField()) + for fieldIndex := 0; fieldIndex < recordType.NumField(); fieldIndex++ { + field := recordType.Field(fieldIndex) + name, options := parseJSONTag(field.Tag.Get("json")) + if name == "" || name == "-" { + t.Fatalf("%s.%s has invalid JSON tag", recordType.Name(), field.Name) + } + fieldType, nullable := manifestType(field.Type, recordType.Name(), name) + item := manifestField{Name: name, Type: fieldType, Required: !options["omitempty"], Nullable: nullable} + if recordType == reflect.TypeOf(document{}) && name == "schemaVersion" { + item.Constant = "2" + } + if recordType == reflect.TypeOf(document{}) && name == "kind" { + item.Constant = DocumentKind + } + fields = append(fields, item) + } + records[recordIndex] = manifestRecord{RecordID: recordType.Name(), Fields: fields} + } + sort.Slice(records, func(left, right int) bool { return records[left].RecordID < records[right].RecordID }) + return records +} + +func manifestType(value reflect.Type, recordID string, fieldName string) (string, bool) { + if value == rawMessageType && recordID == "metadataFields" && fieldName == "deferral" { + return "record:deferral", true + } + for value.Kind() == reflect.Pointer { + value = value.Elem() + } + switch value.Kind() { + case reflect.String: + return "string", false + case reflect.Bool: + return "boolean", false + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return "integer", false + case reflect.Slice: + child, _ := manifestType(value.Elem(), recordID, fieldName) + return "array:" + child, false + case reflect.Map: + if value.Key().Kind() == reflect.String && value.Elem().Kind() == reflect.String { + return "map:string", false + } + case reflect.Struct: + return "record:" + value.Name(), false + } + return "unsupported:" + value.String(), false +} + +func assertShapeRecord(t *testing.T, recordID string, actual *shape, records map[string]manifestRecord, seen map[string]struct{}) { + t.Helper() + if _, exists := seen[recordID]; exists { + return + } + record, exists := records[recordID] + if !exists { + t.Fatalf("shape references unclassified record %q", recordID) + } + seen[recordID] = struct{}{} + if actual.kind != shapeObject || actual.dynamic != nil || len(actual.fields) != len(record.Fields) { + t.Fatalf("shape for %s is not an exact closed record", recordID) + } + for _, field := range record.Fields { + shapeField, exists := actual.fields[field.Name] + if !exists || shapeField.required != field.Required || shapeField.shape.nullable != field.Nullable { + t.Fatalf("shape field %s.%s mismatch", recordID, field.Name) + } + assertShapeType(t, field.Type, shapeField.shape, records, seen) + if field.Constant != "" { + switch shapeField.shape.kind { + case shapeString: + if shapeField.shape.exactString != field.Constant { + t.Fatalf("shape constant %s.%s mismatch", recordID, field.Name) + } + case shapeInteger: + if shapeField.shape.exactInt == nil || field.Constant != "2" || *shapeField.shape.exactInt != SchemaVersion { + t.Fatalf("shape constant %s.%s mismatch", recordID, field.Name) + } + default: + t.Fatalf("unsupported constant type at %s.%s", recordID, field.Name) + } + } + } +} + +func assertShapeType(t *testing.T, expected string, actual *shape, records map[string]manifestRecord, seen map[string]struct{}) { + t.Helper() + switch { + case expected == "string" && actual.kind == shapeString: + return + case expected == "boolean" && actual.kind == shapeBoolean: + return + case expected == "integer" && actual.kind == shapeInteger: + return + case expected == "map:string" && actual.kind == shapeObject && actual.dynamic != nil && actual.dynamic.kind == shapeString: + return + case strings.HasPrefix(expected, "array:") && actual.kind == shapeArray: + assertShapeType(t, strings.TrimPrefix(expected, "array:"), actual.element, records, seen) + return + case strings.HasPrefix(expected, "record:") && actual.kind == shapeObject: + assertShapeRecord(t, strings.TrimPrefix(expected, "record:"), actual, records, seen) + return + default: + t.Fatalf("shape type mismatch: expected %s, actual kind %d", expected, actual.kind) + } +} diff --git a/internal/kernel/requirementsourcecodec/fixture_test.go b/internal/kernel/requirementsourcecodec/fixture_test.go new file mode 100644 index 0000000..1c54584 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/fixture_test.go @@ -0,0 +1,147 @@ +package requirementsourcecodec + +import ( + "reflect" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +func testDraft() requirementsourcemodel.Draft { + active := requirementsourcemodel.Lifecycle{State: requirementsourcemodel.LifecycleActive} + profile := requirementsourcemodel.Profile{ + ProfileID: "RPROF-CODEC-BLOCKING", + Fields: requirementsourcemodel.MetadataFields{ + OwnerID: requirementsourcemodel.Own("proofkit.codec"), + ClaimLevel: requirementsourcemodel.Own(requirementsourcemodel.ClaimBlocking), + RiskClass: requirementsourcemodel.Own(requirementsourcemodel.RiskHigh), + UpdatePolicy: requirementsourcemodel.Own(requirementsourcemodel.UpdatePolicy{ + ReviewOwnerID: "proofkit.codec", + RequiresImpactDeclaration: true, + RequiresProofBindingReview: true, + }), + }, + } + memberFields := requirementsourcemodel.MetadataFields{ + NonClaimRefs: requirementsourcemodel.Own([]string{"NCL-CODEC-002"}), + Lifecycle: requirementsourcemodel.Own(active), + Deferral: requirementsourcemodel.Own[*requirementsourcemodel.Deferral](nil), + } + completeFields := func(claim requirementsourcemodel.ClaimLevel, lifecycle requirementsourcemodel.Lifecycle, deferral *requirementsourcemodel.Deferral) requirementsourcemodel.MetadataFields { + return requirementsourcemodel.MetadataFields{ + OwnerID: requirementsourcemodel.Own("proofkit.codec"), + ClaimLevel: requirementsourcemodel.Own(claim), + RiskClass: requirementsourcemodel.Own(requirementsourcemodel.RiskMedium), + NonClaimRefs: requirementsourcemodel.Own([]string{"NCL-CODEC-002"}), + Lifecycle: requirementsourcemodel.Own(lifecycle), + Deferral: requirementsourcemodel.Own(deferral), + UpdatePolicy: requirementsourcemodel.Own(requirementsourcemodel.UpdatePolicy{ + ReviewOwnerID: "proofkit.codec", + RequiresImpactDeclaration: true, + RequiresProofBindingReview: true, + }), + } + } + deferral := &requirementsourcemodel.Deferral{ + OwnerID: "proofkit.codec", + RiskAcceptedBy: "proofkit.owner", + ReviewCondition: "Review after the codec experiment.", + ExpiryRef: "proofkit.codec.expiry", + MergePolicy: "proofkit.codec.merge", + EvidenceRefs: []string{"docs/evidence/codec-deferral.md"}, + } + return requirementsourcemodel.Draft{ + SourceID: "proofkit.codec.source", + SpecPackagePath: "docs/specs/proofkit-codec", + SourceNonClaimRefs: []string{"NCL-CODEC-001"}, + NonClaimDefinitions: []requirementsourcemodel.NonClaimDefinition{ + {NonClaimID: "NCL-CODEC-001", Statement: "The codec does not prove implementation correctness."}, + {NonClaimID: "NCL-CODEC-002", Statement: "A declared requirement does not prove its satisfaction."}, + {NonClaimID: "NCL-CODEC-003", Statement: "Scenario examples are not exhaustive proof."}, + {NonClaimID: "NCL-CODEC-004", Statement: "Derivation provenance does not prove requirement correctness."}, + }, + Vocabulary: []requirementsourcemodel.VocabularyTerm{ + {TermID: "TERM-CODEC-SERVICE", Kind: requirementsourcemodel.TermSubject, Label: "service", Definition: "The bounded service under specification."}, + }, + Derivations: []requirementsourcemodel.Derivation{ + { + DerivationID: "DRV-CODEC-001", + SourceKind: requirementsourcemodel.SourceOwnerDecision, + SourceRef: requirementsourcemodel.GitBlobRef{ + ObjectFormat: requirementsourcemodel.ObjectSHA1, + CommitOID: "0123456789abcdef0123456789abcdef01234567", + Path: "docs/decisions/codec.md", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, + Selector: requirementsourcemodel.ByteRange{Start: 0, End: 64}, + RequirementIDs: []string{"REQ-CODEC-001", "REQ-CODEC-002"}, + NonClaimRefs: []string{"NCL-CODEC-004"}, + }, + }, + Profiles: []requirementsourcemodel.Profile{profile}, + Groups: []requirementsourcemodel.Group{ + { + GroupID: "RGRP-CODEC-REQUESTS", + ProfileID: "RPROF-CODEC-BLOCKING", + StatementStem: "The service must", + SharedPremises: []string{"The service is available."}, + Members: []requirementsourcemodel.Member{ + {RequirementID: "REQ-CODEC-001", StatementCompletion: "accept requests.", Fields: memberFields}, + {RequirementID: "REQ-CODEC-002", StatementCompletion: "reject malformed requests.", Fields: memberFields}, + }, + }, + { + GroupID: "RGRP-CODEC-DEFERRED", + Members: []requirementsourcemodel.Member{ + {RequirementID: "REQ-CODEC-003", StatementCompletion: "Deferred behavior remains owner-reviewed.", Fields: completeFields(requirementsourcemodel.ClaimDeferred, active, deferral)}, + }, + }, + { + GroupID: "RGRP-CODEC-SUPERSEDED", + Members: []requirementsourcemodel.Member{ + { + RequirementID: "REQ-CODEC-004", + StatementCompletion: "Historical behavior is superseded.", + Fields: completeFields(requirementsourcemodel.ClaimAdvisory, requirementsourcemodel.Lifecycle{ + State: requirementsourcemodel.LifecycleSuperseded, + ReplacementRequirementIDs: []string{"REQ-CODEC-001"}, + EvidenceRefs: []string{"docs/evidence/codec-superseded.md"}, + }, nil), + }, + }, + }, + }, + Scenarios: []requirementsourcemodel.Scenario{ + { + ScenarioID: "SCN-CODEC-REQUEST", + RequirementIDs: []string{"REQ-CODEC-001"}, + Parameters: []string{"surface"}, + Preconditions: []string{"The ${surface} surface is available."}, + ActionSequence: []string{"Submit a request.", "Wait for the response."}, + ExpectedObservations: []string{"The request is accepted."}, + ForbiddenObservations: []string{"The service exposes a secret."}, + Examples: []requirementsourcemodel.Example{ + {ExampleID: "EX-CODEC-REQUEST-001", Values: map[string]requirementsourcemodel.ScenarioValue{"surface": "primary"}}, + {ExampleID: "EX-CODEC-REQUEST-002", Values: map[string]requirementsourcemodel.ScenarioValue{"surface": "secondary"}}, + }, + VocabularyRefs: []string{"TERM-CODEC-SERVICE"}, + NonClaimRefs: []string{"NCL-CODEC-003"}, + }, + }, + } +} + +func mustModel(t *testing.T) requirementsourcemodel.Model { + t.Helper() + model, err := requirementsourcemodel.Normalize(testDraft()) + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + return model +} + +func projectionsEqual(left requirementsourcemodel.Model, right requirementsourcemodel.Model) bool { + return reflect.DeepEqual(left.Atomic(), right.Atomic()) && + reflect.DeepEqual(left.Layout(), right.Layout()) && + reflect.DeepEqual(left.References(), right.References()) +} diff --git a/internal/kernel/requirementsourcecodec/format.go b/internal/kernel/requirementsourcecodec/format.go new file mode 100644 index 0000000..2cefe94 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/format.go @@ -0,0 +1,365 @@ +package requirementsourcecodec + +import ( + "bytes" + "encoding/json" + "reflect" + "sort" + "strconv" + "strings" + "unicode/utf16" + "unicode/utf8" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" + "github.com/research-engineering/agentic-proofkit/internal/kernel/unicodepolicy" +) + +var rawMessageType = reflect.TypeOf(json.RawMessage{}) + +type objectEntry struct { + key string + value reflect.Value +} + +type canonicalWriter struct { + buffer bytes.Buffer + err error +} + +func Format(model requirementsourcemodel.Model) ([]byte, error) { + return FormatWithLimits(model, DefaultLimits(), requirementsourcemodel.DefaultLimits()) +} + +func FormatWithLimits(model requirementsourcemodel.Model, codecLimits Limits, modelLimits requirementsourcemodel.Limits) ([]byte, error) { + if err := validateLimits(codecLimits, modelLimits); err != nil { + return nil, err + } + wire, err := documentFromModel(model) + if err != nil { + return nil, formatError("invalid_model_projection") + } + draft, err := draftFromDocument(wire) + if err != nil { + return nil, formatError("invalid_model_projection") + } + readmitted, err := requirementsourcemodel.NormalizeWithLimits(draft, modelLimits) + if err != nil || !sameModel(model, readmitted) { + return nil, formatError("invalid_model") + } + writer := &canonicalWriter{} + writer.writeHybrid(reflect.ValueOf(wire), 0, true, true) + writer.writeByte('\n') + if writer.err != nil { + return nil, writer.err + } + if int64(writer.buffer.Len()) > codecLimits.MaxOutputBytes { + return nil, formatError("canonical_output_limit_exceeded") + } + return append([]byte(nil), writer.buffer.Bytes()...), nil +} + +func sameModel(left requirementsourcemodel.Model, right requirementsourcemodel.Model) bool { + return reflect.DeepEqual(left.Atomic(), right.Atomic()) && + reflect.DeepEqual(left.Layout(), right.Layout()) && + reflect.DeepEqual(left.References(), right.References()) +} + +func (writer *canonicalWriter) writeHybrid(value reflect.Value, depth int, forceObjectMultiline bool, leadingIndent bool) { + if writer.err != nil { + return + } + value = indirectValue(value) + if !value.IsValid() { + if leadingIndent { + writer.writeIndent(depth) + } + writer.writeString("null") + return + } + if value.Type() == rawMessageType { + if leadingIndent { + writer.writeIndent(depth) + } + writer.writeRawMessage(value.Bytes()) + return + } + switch value.Kind() { + case reflect.Slice, reflect.Array: + if !arrayContainsObject(value) { + if leadingIndent { + writer.writeIndent(depth) + } + writer.writeCompact(value) + return + } + if leadingIndent { + writer.writeIndent(depth) + } + writer.writeByte('[') + for index := 0; index < value.Len(); index++ { + writer.writeByte('\n') + if index == 0 { + writer.writeHybrid(value.Index(index), depth+1, false, true) + } else { + writer.writeIndent(depth) + writer.writeString(", ") + writer.writeHybrid(value.Index(index), depth+1, false, false) + } + } + writer.writeByte('\n') + writer.writeIndent(depth) + writer.writeByte(']') + case reflect.Struct, reflect.Map: + entries := objectEntries(value) + if !forceObjectMultiline && !hasObjectArray(entries) { + if leadingIndent { + writer.writeIndent(depth) + } + writer.writeCompact(value) + return + } + if leadingIndent { + writer.writeIndent(depth) + } + writer.writeByte('{') + for index, entry := range entries { + writer.writeByte('\n') + writer.writeIndent(depth + 1) + writer.writeJSONString(entry.key) + writer.writeString(": ") + if arrayContainsObject(entry.value) { + writer.writeHybrid(entry.value, depth+1, false, false) + } else { + writer.writeCompact(entry.value) + } + if index+1 < len(entries) { + writer.writeByte(',') + } + } + writer.writeByte('\n') + writer.writeIndent(depth) + writer.writeByte('}') + default: + if leadingIndent { + writer.writeIndent(depth) + } + writer.writeCompact(value) + } +} + +func (writer *canonicalWriter) writeCompact(value reflect.Value) { + if writer.err != nil { + return + } + value = indirectValue(value) + if !value.IsValid() { + writer.writeString("null") + return + } + if value.Type() == rawMessageType { + writer.writeRawMessage(value.Bytes()) + return + } + switch value.Kind() { + case reflect.String: + writer.writeJSONString(value.String()) + case reflect.Bool: + writer.writeString(strconv.FormatBool(value.Bool())) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + writer.writeString(strconv.FormatInt(value.Int(), 10)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + writer.writeString(strconv.FormatUint(value.Uint(), 10)) + case reflect.Slice, reflect.Array: + writer.writeByte('[') + for index := 0; index < value.Len(); index++ { + if index > 0 { + writer.writeByte(',') + } + writer.writeCompact(value.Index(index)) + } + writer.writeByte(']') + case reflect.Struct, reflect.Map: + writer.writeByte('{') + entries := objectEntries(value) + for index, entry := range entries { + if index > 0 { + writer.writeByte(',') + } + writer.writeJSONString(entry.key) + writer.writeByte(':') + writer.writeCompact(entry.value) + } + writer.writeByte('}') + default: + writer.err = formatError("unsupported_model_value") + } +} + +func (writer *canonicalWriter) writeRawMessage(payload []byte) { + if bytes.Equal(payload, []byte("null")) { + writer.writeString("null") + return + } + var value deferral + decoder := json.NewDecoder(bytes.NewReader(payload)) + if err := decoder.Decode(&value); err != nil { + writer.err = formatError("invalid_model_projection") + return + } + writer.writeCompact(reflect.ValueOf(value)) +} + +func (writer *canonicalWriter) writeJSONString(value string) { + if !utf8.ValidString(value) { + writer.err = formatError("invalid_model_text") + return + } + writer.writeByte('"') + for _, character := range value { + switch character { + case '"': + writer.writeString(`\"`) + case '\\': + writer.writeString(`\\`) + case '\b': + writer.writeString(`\b`) + case '\f': + writer.writeString(`\f`) + case '\n': + writer.writeString(`\n`) + case '\r': + writer.writeString(`\r`) + case '\t': + writer.writeString(`\t`) + default: + if unicodepolicy.IsUnsafeScalar(character) { + writer.writeUnicodeEscape(character) + } else { + writer.writeString(string(character)) + } + } + } + writer.writeByte('"') +} + +func (writer *canonicalWriter) writeUnicodeEscape(character rune) { + if character <= 0xffff { + writer.writeString(`\u`) + writer.writeString(lowerHex4(uint16(character))) + return + } + high, low := utf16.EncodeRune(character) + writer.writeString(`\u`) + writer.writeString(lowerHex4(uint16(high))) + writer.writeString(`\u`) + writer.writeString(lowerHex4(uint16(low))) +} + +func lowerHex4(value uint16) string { + const digits = "0123456789abcdef" + buffer := [4]byte{} + for index := len(buffer) - 1; index >= 0; index-- { + buffer[index] = digits[value&0xf] + value >>= 4 + } + return string(buffer[:]) +} + +func (writer *canonicalWriter) writeIndent(depth int) { + writer.writeString(strings.Repeat(" ", depth)) +} + +func (writer *canonicalWriter) writeString(value string) { + if writer.err == nil { + _, _ = writer.buffer.WriteString(value) + } +} + +func (writer *canonicalWriter) writeByte(value byte) { + if writer.err == nil { + _ = writer.buffer.WriteByte(value) + } +} + +func indirectValue(value reflect.Value) reflect.Value { + for value.IsValid() && (value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface) { + if value.IsNil() { + return reflect.Value{} + } + value = value.Elem() + } + return value +} + +func arrayContainsObject(value reflect.Value) bool { + value = indirectValue(value) + if !value.IsValid() || (value.Kind() != reflect.Slice && value.Kind() != reflect.Array) { + return false + } + for index := 0; index < value.Len(); index++ { + item := indirectValue(value.Index(index)) + if item.IsValid() && (item.Kind() == reflect.Struct || item.Kind() == reflect.Map) { + return true + } + } + return false +} + +func hasObjectArray(entries []objectEntry) bool { + for _, entry := range entries { + if arrayContainsObject(entry.value) { + return true + } + } + return false +} + +func objectEntries(value reflect.Value) []objectEntry { + value = indirectValue(value) + if !value.IsValid() { + return nil + } + if value.Kind() == reflect.Map { + keys := value.MapKeys() + sort.Slice(keys, func(left, right int) bool { return keys[left].String() < keys[right].String() }) + result := make([]objectEntry, 0, len(keys)) + for _, key := range keys { + result = append(result, objectEntry{key: key.String(), value: value.MapIndex(key)}) + } + return result + } + result := make([]objectEntry, 0, value.NumField()) + typeValue := value.Type() + for index := 0; index < value.NumField(); index++ { + fieldType := typeValue.Field(index) + if fieldType.PkgPath != "" { + continue + } + name, options := parseJSONTag(fieldType.Tag.Get("json")) + if name == "-" { + continue + } + if name == "" { + name = fieldType.Name + } + fieldValue := value.Field(index) + if options["omitempty"] && fieldValue.IsZero() { + continue + } + result = append(result, objectEntry{key: name, value: fieldValue}) + } + return result +} + +func parseJSONTag(tag string) (string, map[string]bool) { + parts := strings.Split(tag, ",") + options := make(map[string]bool, len(parts)-1) + for _, option := range parts[1:] { + options[option] = true + } + return parts[0], options +} + +func formatError(code string) error { + return &Error{diagnostic: Diagnostic{Code: code, CoordinateState: "byte_only"}} +} diff --git a/internal/kernel/requirementsourcecodec/format_layout_test.go b/internal/kernel/requirementsourcecodec/format_layout_test.go new file mode 100644 index 0000000..3760d92 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/format_layout_test.go @@ -0,0 +1,117 @@ +package requirementsourcecodec + +import ( + "bytes" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +func TestHybridLayoutKeepsStableSiblingEntityLinesUnchanged(t *testing.T) { + beforeDraft := testDraft() + before := mustFormatDraft(t, beforeDraft) + afterDraft := testDraft() + afterDraft.Groups[0].Members[1].StatementCompletion = "reject invalid requests." + after := mustFormatDraft(t, afterDraft) + + stableBefore := lineContaining(t, before, `"requirementId":"REQ-CODEC-001"`) + stableAfter := lineContaining(t, after, `"requirementId":"REQ-CODEC-001"`) + if stableBefore != stableAfter { + t.Fatalf("stable sibling line changed:\n-%s\n+%s", stableBefore, stableAfter) + } + changedBefore := lineContaining(t, before, `"requirementId":"REQ-CODEC-002"`) + changedAfter := lineContaining(t, after, `"requirementId":"REQ-CODEC-002"`) + if changedBefore == changedAfter { + t.Fatal("changed entity line did not change") + } + if strings.Contains(changedAfter, "REQ-CODEC-001") { + t.Fatal("changed entity line contains a stable sibling") + } +} + +func TestHybridLayoutUsesCommaFirstEntityArrays(t *testing.T) { + payload := mustPayload(t) + if !bytes.Contains(payload, []byte("\n {\"nonClaimId\":\"NCL-CODEC-001\"")) { + t.Fatal("first entity is not independently line-addressable") + } + if !bytes.Contains(payload, []byte("\n , {\"nonClaimId\":\"NCL-CODEC-002\"")) { + t.Fatal("subsequent entity does not use comma-first layout") + } +} + +func TestCanonicalStringsEscapeUnsafeScalarsWithoutHTMLEscaping(t *testing.T) { + draft := testDraft() + draft.Groups[0].Members[0].StatementCompletion = "accept & requests.\u0085\u200b\u2028\u2029\U000e0001" + payload := mustFormatDraft(t, draft) + if !bytes.Contains(payload, []byte(`accept & requests.\u0085\u200b\u2028\u2029\udb40\udc01`)) { + t.Fatalf("canonical string policy mismatch:\n%s", payload) + } + if bytes.Contains(payload, []byte(`\u003c`)) || bytes.Contains(payload, []byte(`\/`)) { + t.Fatal("formatter applied HTML or slash escaping") + } + parsed, err := Parse(payload) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + expected, err := requirementsourcemodel.Normalize(draft) + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + if !projectionsEqual(parsed.Model, expected) { + t.Fatal("unsafe-scalar escaping changed model semantics") + } +} + +func TestCanonicalMapKeysAreSorted(t *testing.T) { + draft := testDraft() + draft.Scenarios[0].Parameters = []string{"alpha", "surface", "zeta"} + draft.Scenarios[0].Preconditions = []string{"The ${alpha}, ${surface}, and ${zeta} inputs are available."} + draft.Scenarios[0].Examples = []requirementsourcemodel.Example{ + {ExampleID: "EX-CODEC-REQUEST-001", Values: map[string]requirementsourcemodel.ScenarioValue{"zeta": "z", "surface": "primary", "alpha": "a"}}, + {ExampleID: "EX-CODEC-REQUEST-002", Values: map[string]requirementsourcemodel.ScenarioValue{"zeta": "zz", "surface": "secondary", "alpha": "aa"}}, + } + payload := mustFormatDraft(t, draft) + if !bytes.Contains(payload, []byte(`"values":{"alpha":"a","surface":"primary","zeta":"z"}`)) { + t.Fatalf("dynamic map keys are not canonical:\n%s", payload) + } +} + +func TestOrderedActionsRetainOrder(t *testing.T) { + draft := testDraft() + draft.Scenarios[0].ActionSequence = []string{"Third action.", "First action.", "Second action."} + payload := mustFormatDraft(t, draft) + result, err := Parse(payload) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + actions := result.Model.Atomic().Scenarios[0].ActionSequence + want := draft.Scenarios[0].ActionSequence + if strings.Join(actions, "|") != strings.Join(want, "|") { + t.Fatalf("actions = %#v, want %#v", actions, want) + } +} + +func mustFormatDraft(t *testing.T, draft requirementsourcemodel.Draft) []byte { + t.Helper() + model, err := requirementsourcemodel.Normalize(draft) + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + payload, err := Format(model) + if err != nil { + t.Fatalf("Format() error = %v", err) + } + return payload +} + +func lineContaining(t *testing.T, payload []byte, needle string) string { + t.Helper() + for _, line := range strings.Split(string(payload), "\n") { + if strings.Contains(line, needle) { + return line + } + } + t.Fatalf("line containing %q not found", needle) + return "" +} diff --git a/internal/kernel/requirementsourcecodec/json_index.go b/internal/kernel/requirementsourcecodec/json_index.go new file mode 100644 index 0000000..af9c2be --- /dev/null +++ b/internal/kernel/requirementsourcecodec/json_index.go @@ -0,0 +1,312 @@ +package requirementsourcecodec + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "sort" + "strconv" + "strings" + "unicode/utf8" +) + +type rawLocation struct { + key *ByteSpan + value ByteSpan +} + +type indexedValue struct { + value any + locations map[string]rawLocation +} + +type jsonIndexer struct { + source []byte + decoder *json.Decoder + limits Limits + tokens int + locations map[string]rawLocation +} + +func indexJSON(source []byte, limits Limits) (indexedValue, error) { + decoder := json.NewDecoder(bytes.NewReader(source)) + decoder.UseNumber() + indexer := &jsonIndexer{source: source, decoder: decoder, limits: limits, locations: map[string]rawLocation{}} + value, _, err := indexer.parseValue("", 1) + if err != nil { + return indexedValue{}, err + } + before := decoder.InputOffset() + _, err = decoder.Token() + if err == nil { + return indexedValue{}, diagnosticError(source, "multiple_values", "", ByteSpan{Start: before, End: before}, true) + } + if !errors.Is(err, io.EOF) { + return indexedValue{}, syntaxError(source, decoder.InputOffset()) + } + return indexedValue{value: value, locations: indexer.locations}, nil +} + +func (indexer *jsonIndexer) parseValue(path string, depth int) (any, ByteSpan, error) { + if depth > indexer.limits.MaxNesting { + offset := indexer.decoder.InputOffset() + return nil, ByteSpan{}, diagnosticError(indexer.source, "nesting_limit_exceeded", path, ByteSpan{Start: offset, End: offset}, true) + } + token, span, err := indexer.nextToken(path) + if err != nil { + return nil, ByteSpan{}, err + } + delimiter, isDelimiter := token.(json.Delim) + if !isDelimiter { + indexer.locations[path] = rawLocation{value: span} + return token, span, nil + } + switch delimiter { + case '{': + return indexer.parseObject(path, depth, span) + case '[': + return indexer.parseArray(path, depth, span) + default: + return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_syntax", path, span, true) + } +} + +func (indexer *jsonIndexer) parseObject(path string, depth int, opening ByteSpan) (any, ByteSpan, error) { + result := map[string]any{} + seen := map[string]struct{}{} + for indexer.decoder.More() { + keyToken, keySpan, err := indexer.nextToken(path) + if err != nil { + return nil, ByteSpan{}, err + } + key, ok := keyToken.(string) + if !ok { + return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_object_key", path, keySpan, true) + } + if _, exists := seen[key]; exists { + return nil, ByteSpan{}, diagnosticError(indexer.source, "duplicate_field", path, keySpan, true) + } + seen[key] = struct{}{} + childPath := joinPointer(path, key) + value, _, err := indexer.parseValue(childPath, depth+1) + if err != nil { + return nil, ByteSpan{}, err + } + location := indexer.locations[childPath] + location.key = &keySpan + indexer.locations[childPath] = location + result[key] = value + } + closingToken, closingSpan, err := indexer.nextToken(path) + if err != nil { + return nil, ByteSpan{}, err + } + if closingToken != json.Delim('}') { + return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_syntax", path, closingSpan, true) + } + span := ByteSpan{Start: opening.Start, End: closingSpan.End} + location := indexer.locations[path] + location.value = span + indexer.locations[path] = location + return result, span, nil +} + +func (indexer *jsonIndexer) parseArray(path string, depth int, opening ByteSpan) (any, ByteSpan, error) { + result := []any{} + for index := 0; indexer.decoder.More(); index++ { + childPath := joinPointer(path, strconv.Itoa(index)) + value, _, err := indexer.parseValue(childPath, depth+1) + if err != nil { + return nil, ByteSpan{}, err + } + result = append(result, value) + } + closingToken, closingSpan, err := indexer.nextToken(path) + if err != nil { + return nil, ByteSpan{}, err + } + if closingToken != json.Delim(']') { + return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_syntax", path, closingSpan, true) + } + span := ByteSpan{Start: opening.Start, End: closingSpan.End} + location := indexer.locations[path] + location.value = span + indexer.locations[path] = location + return result, span, nil +} + +func (indexer *jsonIndexer) nextToken(path string) (any, ByteSpan, error) { + before := indexer.decoder.InputOffset() + token, err := indexer.decoder.Token() + if err != nil { + return nil, ByteSpan{}, syntaxError(indexer.source, indexer.decoder.InputOffset()) + } + after := indexer.decoder.InputOffset() + indexer.tokens++ + span := tokenSpan(indexer.source, before, after) + if indexer.tokens > indexer.limits.MaxTokens { + return nil, ByteSpan{}, diagnosticError(indexer.source, "token_limit_exceeded", path, span, true) + } + if _, ok := token.(string); ok && !validJSONStringToken(indexer.source[span.Start:span.End]) { + return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_unicode_escape", path, span, true) + } + return token, span, nil +} + +func validJSONStringToken(token []byte) bool { + if len(token) < 2 || token[0] != '"' || token[len(token)-1] != '"' { + return false + } + for index := 1; index < len(token)-1; index++ { + if token[index] != '\\' { + continue + } + index++ + if index >= len(token)-1 { + return false + } + if token[index] != 'u' { + continue + } + value, ok := hexQuad(token, index+1) + if !ok { + return false + } + index += 4 + switch { + case value >= 0xd800 && value <= 0xdbff: + if index+6 >= len(token) || token[index+1] != '\\' || token[index+2] != 'u' { + return false + } + low, lowOK := hexQuad(token, index+3) + if !lowOK || low < 0xdc00 || low > 0xdfff { + return false + } + index += 6 + case value >= 0xdc00 && value <= 0xdfff: + return false + } + } + return true +} + +func hexQuad(source []byte, offset int) (uint16, bool) { + if offset < 0 || offset+4 > len(source) { + return 0, false + } + value := uint16(0) + for _, character := range source[offset : offset+4] { + value <<= 4 + switch { + case character >= '0' && character <= '9': + value |= uint16(character - '0') + case character >= 'a' && character <= 'f': + value |= uint16(character-'a') + 10 + case character >= 'A' && character <= 'F': + value |= uint16(character-'A') + 10 + default: + return 0, false + } + } + return value, true +} + +func tokenSpan(source []byte, before int64, after int64) ByteSpan { + start := before + for start < after { + switch source[start] { + case ' ', '\t', '\r', '\n', ',', ':': + start++ + default: + return ByteSpan{Start: start, End: after} + } + } + return ByteSpan{Start: before, End: after} +} + +func sourceMap(source []byte, locations map[string]rawLocation) SourceMap { + offsets := make([]int64, 0, len(locations)*3) + for _, location := range locations { + offsets = append(offsets, location.value.Start, location.value.End) + if location.key != nil { + offsets = append(offsets, location.key.Start, location.key.End) + } + } + positions := positionsAt(source, offsets) + entries := make(map[string]Location, len(locations)) + for path, raw := range locations { + entries[path] = Location{KeySpan: raw.key, ValueSpan: raw.value, Start: positions[raw.value.Start], End: positions[raw.value.End]} + } + return SourceMap{entries: entries} +} + +func positionsAt(source []byte, offsets []int64) map[int64]Position { + sort.Slice(offsets, func(left, right int) bool { return offsets[left] < offsets[right] }) + unique := offsets[:0] + for _, offset := range offsets { + if len(unique) == 0 || unique[len(unique)-1] != offset { + unique = append(unique, offset) + } + } + result := make(map[int64]Position, len(unique)) + line := 1 + column := 1 + byteOffset := 0 + index := 0 + for index < len(unique) && unique[index] == 0 { + result[unique[index]] = Position{Line: line, ScalarColumn: column} + index++ + } + for byteOffset < len(source) { + value, width := utf8.DecodeRune(source[byteOffset:]) + byteOffset += width + if value == '\n' { + line++ + column = 1 + } else { + column++ + } + for index < len(unique) && int64(byteOffset) >= unique[index] { + result[unique[index]] = Position{Line: line, ScalarColumn: column} + index++ + } + } + for index < len(unique) { + result[unique[index]] = Position{Line: line, ScalarColumn: column} + index++ + } + return result +} + +func diagnosticError(source []byte, code string, path string, span ByteSpan, validUTF8 bool) error { + diagnostic := Diagnostic{Code: code, Path: path, Span: span, CoordinateState: "byte_only"} + if validUTF8 { + positions := positionsAt(source, []int64{span.Start, span.End}) + start := positions[span.Start] + end := positions[span.End] + diagnostic.CoordinateState = "scalar" + diagnostic.Start = &start + diagnostic.End = &end + } + return &Error{diagnostic: diagnostic} +} + +func syntaxError(source []byte, offset int64) error { + if offset < 0 { + offset = 0 + } + if offset > int64(len(source)) { + offset = int64(len(source)) + } + return diagnosticError(source, "invalid_syntax", "", ByteSpan{Start: offset, End: offset}, true) +} + +func joinPointer(parent string, token string) string { + escaped := strings.ReplaceAll(strings.ReplaceAll(token, "~", "~0"), "/", "~1") + return parent + "/" + escaped +} + +func sortStrings(values []string) { + sort.Strings(values) +} diff --git a/internal/kernel/requirementsourcecodec/limit_manifest_test.go b/internal/kernel/requirementsourcecodec/limit_manifest_test.go new file mode 100644 index 0000000..2c9a243 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/limit_manifest_test.go @@ -0,0 +1,119 @@ +package requirementsourcecodec + +import ( + "bytes" + "encoding/json" + "os" + "reflect" + "sort" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +type coefficientManifest struct { + SchemaVersion int `json:"schemaVersion"` + Kind string `json:"kind"` + CanonicalByteBaseOverhead int `json:"canonicalByteBaseOverhead"` + LexicalTokenBaseOverhead int `json:"lexicalTokenBaseOverhead"` + MinimumJSONNesting int `json:"minimumJsonNesting"` + MaximumJSONNesting int `json:"maximumJsonNesting"` + CanonicalByteCoefficients []coefficientItem `json:"canonicalByteCoefficients"` + LexicalTokenCoefficients []coefficientItem `json:"lexicalTokenCoefficients"` +} + +type coefficientItem struct { + ID string `json:"id"` + Coefficient uint64 `json:"coefficient"` +} + +func TestLimitCoefficientManifestMatchesProductionFormula(t *testing.T) { + payload, err := os.ReadFile("testdata/codec-limit-coefficients.v1.json") + if err != nil { + t.Fatal(err) + } + manifest, err := admission.DecodeTypedJSON[coefficientManifest](bytes.NewReader(payload), int64(len(payload))) + if err != nil { + t.Fatal(err) + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + var strict coefficientManifest + if err := decoder.Decode(&strict); err != nil { + t.Fatal(err) + } + if manifest.SchemaVersion != 1 || manifest.Kind != "proofkit.requirement-source-codec-limit-coefficients" || + manifest.CanonicalByteBaseOverhead != canonicalByteBaseOverhead || manifest.LexicalTokenBaseOverhead != lexicalTokenBaseOverhead || + manifest.MinimumJSONNesting != minimumJSONNesting || manifest.MaximumJSONNesting != defaultMaxNesting { + t.Fatalf("coefficient manifest identity or constants mismatch: %#v", manifest) + } + limits := requirementsourcemodel.DefaultLimits() + assertCoefficientRows(t, manifest.CanonicalByteCoefficients, canonicalByteCoefficients(limits)) + assertCoefficientRows(t, manifest.LexicalTokenCoefficients, lexicalTokenCoefficients(limits)) +} + +func TestLimitArithmeticRejectsOverflow(t *testing.T) { + limits := requirementsourcemodel.DefaultLimits() + maximumInt := int(^uint(0) >> 1) + limits.MaxTotalTextBytes = maximumInt + if _, err := MaxCanonicalBytes(limits); err == nil { + t.Fatal("MaxCanonicalBytes() accepted overflowing limits") + } + limits = requirementsourcemodel.DefaultLimits() + limits.MaxCollectionItems = maximumInt + if _, err := MaxLexicalTokens(limits); err == nil { + t.Fatal("MaxLexicalTokens() accepted overflowing limits") + } +} + +func TestCanonicalByteBoundCoversWorstAdmittedEscapeExpansion(t *testing.T) { + limits := compactTestModelLimits() + low := 0 + high := limits.MaxTotalTextBytes/2 + 1 + for low+1 < high { + middle := low + (high-low)/2 + draft := testDraft() + draft.NonClaimDefinitions[0].Statement = "X" + strings.Repeat("\u0085", middle) + "Y" + if _, err := requirementsourcemodel.NormalizeWithLimits(draft, limits); err == nil { + low = middle + } else { + high = middle + } + } + draft := testDraft() + draft.NonClaimDefinitions[0].Statement = "X" + strings.Repeat("\u0085", low) + "Y" + model, err := requirementsourcemodel.NormalizeWithLimits(draft, limits) + if err != nil { + t.Fatalf("maximum admitted escape fixture error = %v", err) + } + codecLimits := pairedCodecLimits(t, limits) + payload, err := FormatWithLimits(model, codecLimits, limits) + if err != nil { + t.Fatalf("FormatWithLimits() error = %v", err) + } + if int64(len(payload)) > codecLimits.MaxOutputBytes { + t.Fatalf("canonical bytes = %d, bound = %d", len(payload), codecLimits.MaxOutputBytes) + } + if !bytes.Contains(payload, []byte(`\u0085`)) { + t.Fatal("worst-case admitted control scalar was not escaped") + } + over := testDraft() + over.NonClaimDefinitions[0].Statement = "X" + strings.Repeat("\u0085", high) + "Y" + if _, err := requirementsourcemodel.NormalizeWithLimits(over, limits); requirementsourcemodel.ErrorCode(err) != "text_budget_exceeded" { + t.Fatalf("limit-plus-one model error = %v", err) + } +} + +func assertCoefficientRows(t *testing.T, expected []coefficientItem, actual []limitCoefficient) { + t.Helper() + converted := make([]coefficientItem, len(actual)) + for index, row := range actual { + converted[index] = coefficientItem{ID: row.ID, Coefficient: row.Coefficient} + } + sort.Slice(converted, func(left, right int) bool { return converted[left].ID < converted[right].ID }) + if !reflect.DeepEqual(expected, converted) { + t.Fatalf("coefficient rows = %#v, want %#v", converted, expected) + } +} diff --git a/internal/kernel/requirementsourcecodec/limits.go b/internal/kernel/requirementsourcecodec/limits.go new file mode 100644 index 0000000..5053b0e --- /dev/null +++ b/internal/kernel/requirementsourcecodec/limits.go @@ -0,0 +1,149 @@ +package requirementsourcecodec + +import ( + "errors" + "math" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +const ( + defaultMaxNesting = 64 + minimumJSONNesting = 7 + canonicalByteBaseOverhead = 4096 + lexicalTokenBaseOverhead = 1024 + lexicalTokenItemCoefficient = 32 +) + +type limitCoefficient struct { + ID string + Count int + Coefficient uint64 +} + +func DefaultLimits() Limits { + modelLimits := requirementsourcemodel.DefaultLimits() + maxOutput, err := MaxCanonicalBytes(modelLimits) + if err != nil { + panic("requirementsourcecodec: invalid default model limits") + } + maxTokens, err := MaxLexicalTokens(modelLimits) + if err != nil { + panic("requirementsourcecodec: invalid default token bound") + } + return Limits{ + MaxRawBytes: maxOutput, + MaxTokens: maxTokens, + MaxNesting: defaultMaxNesting, + MaxOutputBytes: maxOutput, + } +} + +func MaxCanonicalBytes(limits requirementsourcemodel.Limits) (int64, error) { + values := []int{ + limits.MaxDefinitions, limits.MaxDerivations, limits.MaxExamples, + limits.MaxGroups, limits.MaxMembers, limits.MaxProfiles, + limits.MaxScenarios, limits.MaxTerms, limits.MaxCollectionItems, + limits.MaxTotalTextBytes, + } + for _, value := range values { + if value <= 0 { + return 0, errors.New("model limits must be positive") + } + } + total := uint64(canonicalByteBaseOverhead) + for _, term := range canonicalByteCoefficients(limits) { + product, ok := checkedMultiply(uint64(term.Count), term.Coefficient) + if !ok { + return 0, errors.New("canonical byte bound overflows") + } + var okAdd bool + total, okAdd = checkedAdd(total, product) + if !okAdd || total > math.MaxInt64 { + return 0, errors.New("canonical byte bound overflows") + } + } + return int64(total), nil +} + +func MaxLexicalTokens(limits requirementsourcemodel.Limits) (int, error) { + total := uint64(lexicalTokenBaseOverhead) + for _, term := range lexicalTokenCoefficients(limits) { + if term.Count <= 0 { + return 0, errors.New("model limits must be positive") + } + product, ok := checkedMultiply(uint64(term.Count), term.Coefficient) + if !ok { + return 0, errors.New("token bound overflows") + } + total, ok = checkedAdd(total, product) + if !ok || total > uint64(math.MaxInt) { + return 0, errors.New("token bound overflows") + } + } + return int(total), nil +} + +func canonicalByteCoefficients(limits requirementsourcemodel.Limits) []limitCoefficient { + return []limitCoefficient{ + {ID: "total_text_bytes", Count: limits.MaxTotalTextBytes, Coefficient: 3}, + {ID: "collection_items", Count: limits.MaxCollectionItems, Coefficient: 32}, + {ID: "definitions", Count: limits.MaxDefinitions, Coefficient: 96}, + {ID: "terms", Count: limits.MaxTerms, Coefficient: 160}, + {ID: "derivations", Count: limits.MaxDerivations, Coefficient: 384}, + {ID: "profiles", Count: limits.MaxProfiles, Coefficient: 448}, + {ID: "groups", Count: limits.MaxGroups, Coefficient: 320}, + {ID: "members", Count: limits.MaxMembers, Coefficient: 768}, + {ID: "scenarios", Count: limits.MaxScenarios, Coefficient: 896}, + {ID: "examples", Count: limits.MaxExamples, Coefficient: 256}, + } +} + +func lexicalTokenCoefficients(limits requirementsourcemodel.Limits) []limitCoefficient { + return []limitCoefficient{ + {ID: "collection_items", Count: limits.MaxCollectionItems, Coefficient: lexicalTokenItemCoefficient}, + {ID: "definitions", Count: limits.MaxDefinitions, Coefficient: lexicalTokenItemCoefficient}, + {ID: "derivations", Count: limits.MaxDerivations, Coefficient: lexicalTokenItemCoefficient}, + {ID: "examples", Count: limits.MaxExamples, Coefficient: lexicalTokenItemCoefficient}, + {ID: "groups", Count: limits.MaxGroups, Coefficient: lexicalTokenItemCoefficient}, + {ID: "members", Count: limits.MaxMembers, Coefficient: lexicalTokenItemCoefficient}, + {ID: "profiles", Count: limits.MaxProfiles, Coefficient: lexicalTokenItemCoefficient}, + {ID: "scenarios", Count: limits.MaxScenarios, Coefficient: lexicalTokenItemCoefficient}, + {ID: "terms", Count: limits.MaxTerms, Coefficient: lexicalTokenItemCoefficient}, + } +} + +func validateLimits(codec Limits, model requirementsourcemodel.Limits) error { + if err := requirementsourcemodel.ValidateLimits(model); err != nil { + return errors.New("invalid model limits") + } + maxOutput, err := MaxCanonicalBytes(model) + if err != nil { + return err + } + maxTokens, err := MaxLexicalTokens(model) + if err != nil { + return err + } + if codec.MaxRawBytes <= 0 || codec.MaxOutputBytes <= 0 || codec.MaxOutputBytes < maxOutput || codec.MaxRawBytes < codec.MaxOutputBytes { + return errors.New("codec byte limits do not cover the paired model") + } + if codec.MaxTokens < maxTokens || codec.MaxNesting < minimumJSONNesting || codec.MaxNesting > defaultMaxNesting { + return errors.New("codec structural limits do not cover the paired model") + } + return nil +} + +func checkedMultiply(left uint64, right uint64) (uint64, bool) { + if right != 0 && left > math.MaxUint64/right { + return 0, false + } + return left * right, true +} + +func checkedAdd(left uint64, right uint64) (uint64, bool) { + if left > math.MaxUint64-right { + return 0, false + } + return left + right, true +} diff --git a/internal/kernel/requirementsourcecodec/limits_test.go b/internal/kernel/requirementsourcecodec/limits_test.go new file mode 100644 index 0000000..f634599 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/limits_test.go @@ -0,0 +1,150 @@ +package requirementsourcecodec + +import ( + "bytes" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +func TestRawByteBoundaryIsExactAndDominatesUTF8(t *testing.T) { + modelLimits := compactTestModelLimits() + codecLimits := pairedCodecLimits(t, modelLimits) + model, err := requirementsourcemodel.NormalizeWithLimits(testDraft(), modelLimits) + if err != nil { + t.Fatalf("NormalizeWithLimits() error = %v", err) + } + payload, err := FormatWithLimits(model, codecLimits, modelLimits) + if err != nil { + t.Fatalf("FormatWithLimits() error = %v", err) + } + remaining := int(codecLimits.MaxRawBytes) - len(payload) + if remaining < 0 { + t.Fatal("canonical payload exceeds configured raw limit") + } + atLimit := append(append([]byte(nil), payload...), bytes.Repeat([]byte{' '}, remaining)...) + if _, err := ParseWithLimits(atLimit, codecLimits, modelLimits); err != nil { + t.Fatalf("ParseWithLimits(exact limit) error = %v", err) + } + overLimit := append([]byte{0xff}, atLimit...) + _, err = ParseWithLimits(overLimit, codecLimits, modelLimits) + assertDiagnostic(t, err, "raw_byte_limit_exceeded", "") + if err.(*Error).Diagnostic().CoordinateState != "byte_only" { + t.Fatal("raw overflow must not inspect invalid UTF-8") + } +} + +func TestTokenAndNestingLimitsPrecedeShapeAdmission(t *testing.T) { + modelLimits := compactTestModelLimits() + codecLimits := pairedCodecLimits(t, modelLimits) + items := codecLimits.MaxTokens + 1 + tokenOverflow := []byte("[" + strings.Repeat("0,", items) + "0]") + if int64(len(tokenOverflow)) > codecLimits.MaxRawBytes { + t.Fatal("token falsifier unexpectedly exceeds raw-byte bound") + } + _, err := ParseWithLimits(tokenOverflow, codecLimits, modelLimits) + if ErrorCode(err) != "token_limit_exceeded" { + t.Fatalf("token overflow error = %v", err) + } + + codecLimits.MaxNesting = minimumJSONNesting + nested := []byte(strings.Repeat("[", minimumJSONNesting+1) + "0" + strings.Repeat("]", minimumJSONNesting+1)) + _, err = ParseWithLimits(nested, codecLimits, modelLimits) + if ErrorCode(err) != "nesting_limit_exceeded" { + t.Fatalf("nested input error = %v", err) + } +} + +func TestRepresentationCollectionLimitPrecedesModelSemantics(t *testing.T) { + modelLimits := compactTestModelLimits() + codecLimits := pairedCodecLimits(t, modelLimits) + payload := mutateRoot(t, mustPayload(t), func(root map[string]any) { + definitions := root["nonClaimDefinitions"].([]any) + root["nonClaimDefinitions"] = append(definitions, definitions[0]) + }) + _, err := ParseWithLimits(payload, codecLimits, modelLimits) + assertDiagnostic(t, err, "collection_limit_exceeded", "/nonClaimDefinitions") +} + +func TestDynamicMapCollectionLimitPrecedesParameterSemantics(t *testing.T) { + modelLimits := compactTestModelLimits() + codecLimits := pairedCodecLimits(t, modelLimits) + payload := mutateRoot(t, mustPayload(t), func(root map[string]any) { + scenarios := root["scenarios"].([]any) + examples := scenarios[0].(map[string]any)["examples"].([]any) + values := examples[0].(map[string]any)["values"].(map[string]any) + for index := 0; index <= modelLimits.MaxCollectionItems; index++ { + values["key"+strings.Repeat("x", index+1)] = "value" + } + }) + _, err := ParseWithLimits(payload, codecLimits, modelLimits) + assertDiagnostic(t, err, "collection_limit_exceeded", "/scenarios/0/examples/0/values") +} + +func TestModelResourcePreflightPrecedesSemanticValidation(t *testing.T) { + modelLimits := compactTestModelLimits() + codecLimits := pairedCodecLimits(t, modelLimits) + payload := mutateRoot(t, mustPayload(t), func(root map[string]any) { + root["sourceId"] = "invalid source id" + definitions := root["nonClaimDefinitions"].([]any) + definitions[0].(map[string]any)["statement"] = strings.Repeat("x", modelLimits.MaxTotalTextBytes+1) + }) + _, err := ParseWithLimits(payload, codecLimits, modelLimits) + if ErrorCode(err) != "text_budget_exceeded" { + t.Fatalf("resource/semantic precedence error = %v", err) + } +} + +func TestCodecLimitsCannotUnderCoverModel(t *testing.T) { + modelLimits := compactTestModelLimits() + codecLimits := pairedCodecLimits(t, modelLimits) + codecLimits.MaxOutputBytes-- + _, err := ParseWithLimits(mustPayload(t), codecLimits, modelLimits) + if err == nil || ErrorCode(err) != "" { + t.Fatalf("invalid configuration error = %v", err) + } + + codecLimits = pairedCodecLimits(t, modelLimits) + codecLimits.MaxNesting = minimumJSONNesting - 1 + _, err = ParseWithLimits(mustPayload(t), codecLimits, modelLimits) + if err == nil || ErrorCode(err) != "" { + t.Fatalf("under-nested configuration error = %v", err) + } + + modelLimits.MaxExpandedItems = 0 + _, err = ParseWithLimits(mustPayload(t), pairedCodecLimits(t, compactTestModelLimits()), modelLimits) + if err == nil || ErrorCode(err) != "" || err.Error() != "invalid model limits" { + t.Fatalf("invalid model configuration error = %v", err) + } +} + +func compactTestModelLimits() requirementsourcemodel.Limits { + limits := requirementsourcemodel.DefaultLimits() + limits.MaxDefinitions = 4 + limits.MaxDerivations = 1 + limits.MaxExamples = 2 + limits.MaxExamplesPerScenario = 2 + limits.MaxCollectionItems = 128 + limits.MaxGroups = 3 + limits.MaxMembers = 4 + limits.MaxMembersPerGroup = 2 + limits.MaxProfiles = 1 + limits.MaxScenarios = 1 + limits.MaxTerms = 1 + limits.MaxTotalTextBytes = 16 << 10 + return limits +} + +func pairedCodecLimits(t testing.TB, modelLimits requirementsourcemodel.Limits) Limits { + t.Helper() + maxBytes, err := MaxCanonicalBytes(modelLimits) + if err != nil { + t.Fatalf("MaxCanonicalBytes() error = %v", err) + } + maxTokens, err := MaxLexicalTokens(modelLimits) + if err != nil { + t.Fatalf("MaxLexicalTokens() error = %v", err) + } + return Limits{MaxRawBytes: maxBytes, MaxTokens: maxTokens, MaxNesting: defaultMaxNesting, MaxOutputBytes: maxBytes} +} diff --git a/internal/kernel/requirementsourcecodec/mutant_manifest_test.go b/internal/kernel/requirementsourcecodec/mutant_manifest_test.go new file mode 100644 index 0000000..6a76205 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/mutant_manifest_test.go @@ -0,0 +1,140 @@ +package requirementsourcecodec + +import ( + "bytes" + "encoding/json" + "os" + "reflect" + "sort" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" +) + +type codecMutantManifest struct { + SchemaVersion int `json:"schemaVersion"` + Kind string `json:"kind"` + Mutants []codecMutant `json:"mutants"` +} + +type codecMutant struct { + MutantID string `json:"mutantId"` + Layer string `json:"layer"` + ExpectedCode string `json:"expectedCode"` + ExpectedPath string `json:"expectedPath"` + Property string `json:"property"` +} + +func TestCodecMutantManifestClosesRepresentationFailures(t *testing.T) { + manifest := readCodecMutants(t) + if manifest.SchemaVersion != 1 || manifest.Kind != "proofkit.requirement-source-codec-mutants" { + t.Fatalf("mutant manifest identity = %#v", manifest) + } + ids := make([]string, len(manifest.Mutants)) + allowedLayers := map[string]bool{"lexical": true, "model": true, "raw": true, "shape": true, "syntax": true} + for index, mutant := range manifest.Mutants { + ids[index] = mutant.MutantID + if !allowedLayers[mutant.Layer] || mutant.ExpectedCode == "" || mutant.Property == "" { + t.Fatalf("incomplete mutant row %#v", mutant) + } + payload := codecMutantPayload(t, mutant.MutantID) + _, err := Parse(payload) + assertDiagnostic(t, err, mutant.ExpectedCode, mutant.ExpectedPath) + if strings.Contains(mutant.MutantID, "secret") || strings.Contains(mutant.MutantID, "dynamic_key") || strings.Contains(mutant.MutantID, "unknown_field") { + const sentinel = "ghp_0123456789abcdefghijklmnopqrstuvwxyz" + if strings.Contains(err.Error(), sentinel) || strings.Contains(err.(*Error).Diagnostic().Path, sentinel) { + t.Fatalf("mutant %s disclosed caller text", mutant.MutantID) + } + } + } + want := append([]string(nil), ids...) + sort.Strings(want) + if !reflect.DeepEqual(ids, want) { + t.Fatalf("mutant IDs = %v, want sorted unique %v", ids, want) + } + for index := 1; index < len(ids); index++ { + if ids[index-1] == ids[index] { + t.Fatalf("duplicate mutant ID %q", ids[index]) + } + } + expectedIDs := []string{ + "case_folded_field", "duplicate_field", "dynamic_key_wrong_type", "explicit_null", + "fractional_integer", "invalid_utf8", "lone_surrogate", "missing_required", + "multiple_values", "negative_zero", "secret_shaped_text", "semantic_duplicate_id", + "unknown_field", "wrong_identity", "wrong_type", + } + if !reflect.DeepEqual(ids, expectedIDs) { + t.Fatalf("mutant IDs = %v, want exact registry %v", ids, expectedIDs) + } +} + +func readCodecMutants(t *testing.T) codecMutantManifest { + t.Helper() + payload, err := os.ReadFile("testdata/codec-mutants.v1.json") + if err != nil { + t.Fatal(err) + } + manifest, err := admission.DecodeTypedJSON[codecMutantManifest](bytes.NewReader(payload), int64(len(payload))) + if err != nil { + t.Fatal(err) + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + var strict codecMutantManifest + if err := decoder.Decode(&strict); err != nil { + t.Fatal(err) + } + return manifest +} + +func codecMutantPayload(t *testing.T, mutantID string) []byte { + t.Helper() + const sentinel = "ghp_0123456789abcdefghijklmnopqrstuvwxyz" + valid := mustPayload(t) + switch mutantID { + case "case_folded_field": + return mutateRoot(t, valid, func(root map[string]any) { root["SourceId"] = root["sourceId"]; delete(root, "sourceId") }) + case "duplicate_field": + return duplicateRootField(t, valid, "sourceId") + case "dynamic_key_wrong_type": + return mutateRoot(t, valid, func(root map[string]any) { + scenarios := root["scenarios"].([]any) + examples := scenarios[0].(map[string]any)["examples"].([]any) + examples[0].(map[string]any)["values"].(map[string]any)[sentinel] = true + }) + case "explicit_null": + return mutateRoot(t, valid, func(root map[string]any) { root["sourceId"] = nil }) + case "fractional_integer": + return mutateRoot(t, valid, func(root map[string]any) { root["schemaVersion"] = json.Number("2.0") }) + case "invalid_utf8": + return []byte{0xff} + case "lone_surrogate": + return bytes.Replace(valid, []byte(`"The codec does not prove implementation correctness."`), []byte(`"\ud800"`), 1) + case "missing_required": + return mutateRoot(t, valid, func(root map[string]any) { delete(root, "sourceId") }) + case "multiple_values": + return append(append([]byte(nil), valid...), []byte("{}")...) + case "negative_zero": + return bytes.Replace(valid, []byte(`"start":0`), []byte(`"start":-0`), 1) + case "secret_shaped_text": + return mutateRoot(t, valid, func(root map[string]any) { + definitions := root["nonClaimDefinitions"].([]any) + definitions[0].(map[string]any)["statement"] = "token=" + sentinel + }) + case "semantic_duplicate_id": + return mutateRoot(t, valid, func(root map[string]any) { + definitions := root["nonClaimDefinitions"].([]any) + definitions[1].(map[string]any)["nonClaimId"] = definitions[0].(map[string]any)["nonClaimId"] + }) + case "unknown_field": + return mutateRoot(t, valid, func(root map[string]any) { root[sentinel] = true }) + case "wrong_identity": + return mutateRoot(t, valid, func(root map[string]any) { root["schemaVersion"] = json.Number("3") }) + case "wrong_type": + return mutateRoot(t, valid, func(root map[string]any) { root["sourceId"] = true }) + default: + t.Fatalf("mutant %q has no executable factory", mutantID) + return nil + } +} diff --git a/internal/kernel/requirementsourcecodec/package_boundary_test.go b/internal/kernel/requirementsourcecodec/package_boundary_test.go new file mode 100644 index 0000000..ed0907f --- /dev/null +++ b/internal/kernel/requirementsourcecodec/package_boundary_test.go @@ -0,0 +1,89 @@ +package requirementsourcecodec + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "reflect" + "sort" + "strconv" + "strings" + "testing" +) + +func TestProductionPackageHasOneCodecAndNoSyntaxDependency(t *testing.T) { + allowedOwners := map[string]struct{}{ + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel": {}, + "github.com/research-engineering/agentic-proofkit/internal/kernel/unicodepolicy": {}, + } + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + exported := []string{} + productionFiles := []string{} + for _, path := range files { + if strings.HasSuffix(path, "_test.go") { + continue + } + productionFiles = append(productionFiles, filepath.Base(path)) + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + t.Fatal(err) + } + for _, imported := range parsed.Imports { + pathValue, err := strconv.Unquote(imported.Path.Value) + if err != nil { + t.Fatal(err) + } + if strings.HasPrefix(pathValue, "github.com/research-engineering/agentic-proofkit/") { + if _, exists := allowedOwners[pathValue]; !exists { + t.Fatalf("codec imports an unapproved in-repository owner %q", pathValue) + } + } + lower := strings.ToLower(pathValue) + if strings.Contains(lower, "yaml") || strings.Contains(lower, "toml") { + t.Fatalf("codec imports losing syntax dependency %q", pathValue) + } + } + for _, declaration := range parsed.Decls { + switch value := declaration.(type) { + case *ast.FuncDecl: + if value.Recv == nil && ast.IsExported(value.Name.Name) { + exported = append(exported, "func:"+value.Name.Name) + } + case *ast.GenDecl: + for _, specification := range value.Specs { + switch item := specification.(type) { + case *ast.TypeSpec: + if ast.IsExported(item.Name.Name) { + exported = append(exported, "type:"+item.Name.Name) + } + case *ast.ValueSpec: + for _, name := range item.Names { + if ast.IsExported(name.Name) { + exported = append(exported, "value:"+name.Name) + } + } + } + } + } + } + } + wantFiles := []string{"document.go", "format.go", "json_index.go", "limits.go", "parse.go", "shape.go", "types.go"} + if !reflect.DeepEqual(productionFiles, wantFiles) { + t.Fatalf("production codec files = %v, want exact single-grammar surface %v", productionFiles, wantFiles) + } + sort.Strings(exported) + want := []string{ + "func:DefaultLimits", "func:ErrorCode", "func:Format", "func:FormatWithLimits", + "func:MaxCanonicalBytes", "func:MaxLexicalTokens", "func:Parse", "func:ParseWithLimits", + "type:ByteSpan", "type:Diagnostic", "type:Error", "type:Limits", "type:Location", + "type:Position", "type:Result", "type:SourceMap", "value:DocumentKind", "value:SchemaVersion", + } + sort.Strings(want) + if !reflect.DeepEqual(exported, want) { + t.Fatalf("exported codec surface = %v, want %v", exported, want) + } +} diff --git a/internal/kernel/requirementsourcecodec/parse.go b/internal/kernel/requirementsourcecodec/parse.go new file mode 100644 index 0000000..992e8c2 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/parse.go @@ -0,0 +1,142 @@ +package requirementsourcecodec + +import ( + "bytes" + "encoding/json" + "strconv" + "strings" + "unicode/utf8" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +func Parse(source []byte) (Result, error) { + return ParseWithLimits(source, DefaultLimits(), requirementsourcemodel.DefaultLimits()) +} + +func ParseWithLimits(source []byte, codecLimits Limits, modelLimits requirementsourcemodel.Limits) (Result, error) { + if err := validateLimits(codecLimits, modelLimits); err != nil { + return Result{}, err + } + if int64(len(source)) > codecLimits.MaxRawBytes { + start := codecLimits.MaxRawBytes + return Result{}, diagnosticError(source, "raw_byte_limit_exceeded", "", ByteSpan{Start: start, End: start + 1}, false) + } + if invalidOffset, ok := firstInvalidUTF8(source); ok { + return Result{}, diagnosticError(source, "invalid_utf8", "", ByteSpan{Start: invalidOffset, End: invalidOffset + 1}, false) + } + + indexed, err := indexJSON(source, codecLimits) + if err != nil { + return Result{}, err + } + if err := validateShape(indexed.value, documentShape(modelLimits), "", indexed.locations, source); err != nil { + return Result{}, err + } + + canonicalValue, err := json.Marshal(indexed.value) + if err != nil { + return Result{}, diagnosticError(source, "invalid_projection", "", indexed.locations[""].value, true) + } + var wire document + decoder := json.NewDecoder(bytes.NewReader(canonicalValue)) + decoder.UseNumber() + if err := decoder.Decode(&wire); err != nil { + return Result{}, diagnosticError(source, "invalid_projection", "", indexed.locations[""].value, true) + } + draft, err := draftFromDocument(wire) + if err != nil { + return Result{}, diagnosticError(source, "invalid_projection", "", indexed.locations[""].value, true) + } + model, err := requirementsourcemodel.NormalizeWithLimits(draft, modelLimits) + if err != nil { + return Result{}, modelDiagnostic(source, indexed.locations, err) + } + return Result{Model: model, SourceMap: sourceMap(source, indexed.locations)}, nil +} + +func firstInvalidUTF8(source []byte) (int64, bool) { + for offset := 0; offset < len(source); { + value, width := utf8.DecodeRune(source[offset:]) + if value == utf8.RuneError && width == 1 { + return int64(offset), true + } + offset += width + } + return 0, false +} + +func modelDiagnostic(source []byte, locations map[string]rawLocation, err error) error { + code := requirementsourcemodel.ErrorCode(err) + validation, ok := err.(*requirementsourcemodel.ValidationError) + if !ok || code == "" { + return diagnosticError(source, "model_admission_failed", "", locations[""].value, true) + } + lookupPath, reportedPath := modelPathPointers(validation.Path) + location := closestLocation(locations, lookupPath) + return diagnosticError(source, code, reportedPath, location.value, true) +} + +func closestLocation(locations map[string]rawLocation, path string) rawLocation { + for current := path; ; { + if location, exists := locations[current]; exists { + return location + } + index := strings.LastIndex(current, "/") + if index < 0 { + break + } + current = current[:index] + } + return locations[""] +} + +func modelPathPointers(path string) (string, string) { + segments := modelPathSegments(path) + lookup := "" + reported := "" + dynamicEntry := false + for _, segment := range segments { + lookup = joinPointer(lookup, segment) + reportedSegment := segment + if dynamicEntry { + reportedSegment = "" + dynamicEntry = false + } + reported = joinPointer(reported, reportedSegment) + if segment == "values" { + dynamicEntry = true + } + } + return lookup, reported +} + +func modelPathSegments(path string) []string { + segments := make([]string, 0, 8) + for offset := 0; offset < len(path); { + switch path[offset] { + case '.': + offset++ + case '[': + end := strings.IndexByte(path[offset:], ']') + if end < 0 { + return segments + } + end += offset + if _, err := strconv.Atoi(path[offset+1 : end]); err == nil { + segments = append(segments, path[offset+1:end]) + } + offset = end + 1 + default: + end := offset + for end < len(path) && path[end] != '.' && path[end] != '[' { + end++ + } + if end > offset { + segments = append(segments, path[offset:end]) + } + offset = end + } + } + return segments +} diff --git a/internal/kernel/requirementsourcecodec/roundtrip_test.go b/internal/kernel/requirementsourcecodec/roundtrip_test.go new file mode 100644 index 0000000..bc84db6 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/roundtrip_test.go @@ -0,0 +1,101 @@ +package requirementsourcecodec + +import ( + "bytes" + "testing" +) + +func TestFormatParseRoundTripPreservesEveryProjection(t *testing.T) { + model := mustModel(t) + payload, err := Format(model) + if err != nil { + t.Fatalf("Format() error = %v", err) + } + result, err := Parse(payload) + if err != nil { + t.Fatalf("Parse(Format()) error = %v\npayload:\n%s", err, payload) + } + if !projectionsEqual(result.Model, model) { + t.Fatal("Parse(Format(model)) changed an admitted projection") + } +} + +func TestCanonicalFormatIsIdempotent(t *testing.T) { + first, err := Format(mustModel(t)) + if err != nil { + t.Fatalf("first Format() error = %v", err) + } + parsed, err := Parse(first) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + second, err := Format(parsed.Model) + if err != nil { + t.Fatalf("second Format() error = %v", err) + } + if !bytes.Equal(first, second) { + t.Fatal("canonical formatting is not idempotent") + } + if len(first) == 0 || first[len(first)-1] != '\n' || bytes.HasSuffix(first, []byte("\n\n")) { + t.Fatal("canonical payload must end in exactly one LF") + } +} + +func TestFormatPreservesMetadataAbsenceNullAndRecord(t *testing.T) { + payload, err := Format(mustModel(t)) + if err != nil { + t.Fatalf("Format() error = %v", err) + } + if !bytes.Contains(payload, []byte(`"deferral":null`)) { + t.Fatal("present-null deferral was not serialized") + } + if !bytes.Contains(payload, []byte(`"reviewCondition":"Review after the codec experiment."`)) { + t.Fatal("present-record deferral was not serialized") + } + if bytes.Contains(payload, []byte(`"profileId":"RPROF-CODEC-BLOCKING","fields":{"ownerId":"proofkit.codec","claimLevel":"blocking","riskClass":"high","nonClaimRefs"`)) { + t.Fatal("absent profile metadata was materialized") + } +} + +func TestSourceMapReplaysKeyAndValueSpans(t *testing.T) { + payload, err := Format(mustModel(t)) + if err != nil { + t.Fatalf("Format() error = %v", err) + } + result, err := Parse(payload) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + location, ok := result.SourceMap.Location("/groups/1/members/1/requirementId") + if !ok || location.KeySpan == nil { + t.Fatal("source map lacks requirementId key/value spans") + } + key := payload[location.KeySpan.Start:location.KeySpan.End] + value := payload[location.ValueSpan.Start:location.ValueSpan.End] + if !bytes.Equal(key, []byte(`"requirementId"`)) || !bytes.Equal(value, []byte(`"REQ-CODEC-002"`)) { + t.Fatalf("source-map replay = key %q value %q", key, value) + } + if location.Start.Line <= 0 || location.Start.ScalarColumn <= 0 || location.End.Line <= 0 || location.End.ScalarColumn <= 0 { + t.Fatal("valid UTF-8 source map lacks scalar coordinates") + } +} + +func TestReturnedSourceMapIsImmutable(t *testing.T) { + payload, err := Format(mustModel(t)) + if err != nil { + t.Fatalf("Format() error = %v", err) + } + result, err := Parse(payload) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + first, ok := result.SourceMap.Location("/sourceId") + if !ok || first.KeySpan == nil { + t.Fatal("sourceId location missing") + } + first.KeySpan.Start = -1 + second, ok := result.SourceMap.Location("/sourceId") + if !ok || second.KeySpan == nil || second.KeySpan.Start < 0 { + t.Fatal("caller mutation escaped into source-map owner state") + } +} diff --git a/internal/kernel/requirementsourcecodec/selection_evaluator_test.go b/internal/kernel/requirementsourcecodec/selection_evaluator_test.go new file mode 100644 index 0000000..87f94e5 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/selection_evaluator_test.go @@ -0,0 +1,170 @@ +package requirementsourcecodec + +import ( + "sort" + "testing" +) + +func selectJSONLayout(record codecSelection) string { + maximumAccuracy := 0 + jsonIDs := stringSet(record.Roles.JSONLayouts) + for _, observation := range record.ScreenObservations { + if _, exists := jsonIDs[observation.CandidateID]; exists && observation.ReviewAccuracyBasisPoints != nil && *observation.ReviewAccuracyBasisPoints > maximumAccuracy { + maximumAccuracy = *observation.ReviewAccuracyBasisPoints + } + } + eligible := []screenObservation{} + for _, observation := range record.ScreenObservations { + _, isJSON := jsonIDs[observation.CandidateID] + if isJSON && observation.FieldClosure == "passed" && observation.ReviewAccuracyBasisPoints != nil && *observation.ReviewAccuracyBasisPoints == maximumAccuracy && observation.InvalidMutationFalseAccepts != nil && *observation.InvalidMutationFalseAccepts == 0 && observation.EditLocality { + eligible = append(eligible, observation) + } + } + sort.Slice(eligible, func(left, right int) bool { + leftKey := []int{eligible[left].WeightedTokensO200kBase, eligible[left].WeightedCanonicalBytes, eligible[left].ChangedBytes, eligible[left].ChangedLines} + rightKey := []int{eligible[right].WeightedTokensO200kBase, eligible[right].WeightedCanonicalBytes, eligible[right].ChangedBytes, eligible[right].ChangedLines} + for index := range leftKey { + if leftKey[index] != rightKey[index] { + return leftKey[index] < rightKey[index] + } + } + return eligible[left].CandidateID < eligible[right].CandidateID + }) + if len(eligible) == 0 { + return "" + } + return eligible[0].CandidateID +} + +func challengerEligible(record codecSelection) bool { + selectedJSON := selectJSONLayout(record) + if selectedJSON == "" || len(record.Roles.RestrictedTextChallengers) != 1 { + return false + } + baseline, baselineExists := observationByID(record.ScreenObservations, selectedJSON) + challenger, challengerExists := observationByID(record.ScreenObservations, record.Roles.RestrictedTextChallengers[0]) + if !baselineExists || !challengerExists { + return false + } + policy := record.ReplacementPolicy + return challenger.FieldClosure == "passed" && + challenger.ReviewAccuracyBasisPoints != nil && *challenger.ReviewAccuracyBasisPoints >= policy.MinimumReviewAccuracyBasisPoints && + challenger.InvalidMutationFalseAccepts != nil && *challenger.InvalidMutationFalseAccepts <= policy.MaximumInvalidMutationFalseAccepts && + challenger.EditLocality && + materiallyBetter(baseline.WeightedCanonicalBytes, challenger.WeightedCanonicalBytes, policy.MinimumByteImprovementBasisPoints) && + materiallyBetter(baseline.WeightedTokensO200kBase, challenger.WeightedTokensO200kBase, policy.MinimumTokenImprovementBasisPoints) && + challenger.AggregateDiffState == "passed" && challenger.PerEditDiffState == "passed" && + challenger.ParseTimeState == "passed" && challenger.FormatTimeState == "passed" && + challenger.LowerCostDominanceState == "passed" && + withinRatio(baseline.ProjectedProductionLOC, challenger.ProjectedProductionLOC, policy.MaximumProjectedProductionCostBasisPoints) && + withinRatio(baseline.ProjectedProductionBranches, challenger.ProjectedProductionBranches, policy.MaximumProjectedProductionCostBasisPoints) +} + +func observationByID(observations []screenObservation, candidateID string) (screenObservation, bool) { + for _, observation := range observations { + if observation.CandidateID == candidateID { + return observation, true + } + } + return screenObservation{}, false +} + +func materiallyBetter(baseline int, candidate int, minimumBasisPoints int) bool { + if baseline <= 0 || candidate < 0 || candidate >= baseline || minimumBasisPoints < 0 { + return false + } + return int64(baseline-candidate)*10000 >= int64(baseline)*int64(minimumBasisPoints) +} + +func withinRatio(baseline int, candidate int, maximumBasisPoints int) bool { + if baseline <= 0 || candidate < 0 || maximumBasisPoints < 0 { + return false + } + return int64(candidate)*10000 <= int64(baseline)*int64(maximumBasisPoints) +} + +func TestChallengerEligibilityRequiresEveryReplacementPredicate(t *testing.T) { + eligible := eligibleChallengerRecord(t) + if !challengerEligible(eligible) { + t.Fatal("complete strict replacement was rejected") + } + + tests := []struct { + name string + mutate func(*codecSelection) + }{ + {name: "accepted grouped JSON", mutate: func(value *codecSelection) { + mutateObservation(value, "json-hybrid-v1", func(item *screenObservation) { item.EditLocality = false }) + }}, + {name: "unique challenger", mutate: func(value *codecSelection) { + value.Roles.RestrictedTextChallengers = append(value.Roles.RestrictedTextChallengers, "proofkit-source-text-v2") + }}, + {name: "challenger observation", mutate: func(value *codecSelection) { value.Roles.RestrictedTextChallengers[0] = "proofkit-source-text-missing" }}, + {name: "field closure", mutate: challengerMutation(func(item *screenObservation) { item.FieldClosure = "failed" })}, + {name: "review present", mutate: challengerMutation(func(item *screenObservation) { item.ReviewAccuracyBasisPoints = nil })}, + {name: "review threshold", mutate: challengerMutation(func(item *screenObservation) { item.ReviewAccuracyBasisPoints = integerPointer(9999) })}, + {name: "invalid-mutation result", mutate: challengerMutation(func(item *screenObservation) { item.InvalidMutationFalseAccepts = nil })}, + {name: "invalid-mutation threshold", mutate: challengerMutation(func(item *screenObservation) { item.InvalidMutationFalseAccepts = integerPointer(1) })}, + {name: "edit locality", mutate: challengerMutation(func(item *screenObservation) { item.EditLocality = false })}, + {name: "material byte improvement", mutate: challengerMutation(func(item *screenObservation) { item.WeightedCanonicalBytes = 2605964 })}, + {name: "material token improvement", mutate: challengerMutation(func(item *screenObservation) { item.WeightedTokensO200kBase = 698724 })}, + {name: "aggregate diff", mutate: challengerMutation(func(item *screenObservation) { item.AggregateDiffState = "failed" })}, + {name: "per-edit diff", mutate: challengerMutation(func(item *screenObservation) { item.PerEditDiffState = "failed" })}, + {name: "parse time", mutate: challengerMutation(func(item *screenObservation) { item.ParseTimeState = "missing" })}, + {name: "format time", mutate: challengerMutation(func(item *screenObservation) { item.FormatTimeState = "missing" })}, + {name: "lower-cost dominance", mutate: challengerMutation(func(item *screenObservation) { item.LowerCostDominanceState = "failed" })}, + {name: "production LOC", mutate: challengerMutation(func(item *screenObservation) { item.ProjectedProductionLOC = 803 })}, + {name: "production branches", mutate: challengerMutation(func(item *screenObservation) { item.ProjectedProductionBranches = 70 })}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + record := eligibleChallengerRecord(t) + test.mutate(&record) + if challengerEligible(record) { + t.Fatal("incomplete replacement predicate was accepted") + } + }) + } +} + +func eligibleChallengerRecord(t *testing.T) codecSelection { + t.Helper() + record := readCodecSelection(t) + record.ScreenObservations = append([]screenObservation(nil), record.ScreenObservations...) + record.Roles.RestrictedTextChallengers = append([]string(nil), record.Roles.RestrictedTextChallengers...) + mutateObservation(&record, "proofkit-source-text-v1", func(item *screenObservation) { + item.FieldClosure = "passed" + item.ReviewAccuracyBasisPoints = integerPointer(10000) + item.InvalidMutationFalseAccepts = integerPointer(0) + item.EditLocality = true + item.WeightedCanonicalBytes = 2_300_000 + item.WeightedTokensO200kBase = 620_000 + item.ProjectedProductionLOC = 600 + item.ProjectedProductionBranches = 60 + item.AggregateDiffState = "passed" + item.PerEditDiffState = "passed" + item.ParseTimeState = "passed" + item.FormatTimeState = "passed" + item.LowerCostDominanceState = "passed" + }) + return record +} + +func challengerMutation(mutate func(*screenObservation)) func(*codecSelection) { + return func(value *codecSelection) { + mutateObservation(value, "proofkit-source-text-v1", mutate) + } +} + +func mutateObservation(record *codecSelection, candidateID string, mutate func(*screenObservation)) { + for index := range record.ScreenObservations { + if record.ScreenObservations[index].CandidateID == candidateID { + mutate(&record.ScreenObservations[index]) + return + } + } +} + +func integerPointer(value int) *int { + return &value +} diff --git a/internal/kernel/requirementsourcecodec/selection_test.go b/internal/kernel/requirementsourcecodec/selection_test.go new file mode 100644 index 0000000..2f43f94 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/selection_test.go @@ -0,0 +1,355 @@ +package requirementsourcecodec + +import ( + "bytes" + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "reflect" + "sort" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +type codecSelection struct { + SchemaVersion int `json:"schemaVersion"` + Kind string `json:"kind"` + ScreenEvidence screenEvidence `json:"screenEvidence"` + Roles selectionRoles `json:"roles"` + MetricRegistry []selectionMetric `json:"metricRegistry"` + ReplacementPolicy replacementPolicy `json:"replacementPolicy"` + ScreenObservations []screenObservation `json:"screenObservations"` + Decision selectionDecision `json:"decision"` + HardGateSelectors []string `json:"hardGateSelectors"` + NonClaims []string `json:"nonClaims"` +} + +type screenEvidence struct { + ScreenManifestVersion int `json:"screenManifestVersion"` + ScreenManifestSHA256 string `json:"screenManifestSha256"` + SelectionOpeningSHA256 string `json:"selectionOpeningSha256"` + FixtureIndexSHA256 string `json:"fixtureIndexSha256"` + ObservationsSHA256 string `json:"observationsSha256"` + IndependentValidationSHA256 string `json:"independentValidationSha256"` + ReviewResultsSHA256 string `json:"reviewResultsSha256"` + TokenProducerDigests map[string]string `json:"tokenProducerDigests"` +} + +type selectionRoles struct { + StatusQuoComparators []string `json:"statusQuoComparators"` + ModelAblations []string `json:"modelAblations"` + ScreenOnlyComparators []string `json:"screenOnlyComparators"` + JSONLayouts []string `json:"jsonLayouts"` + RestrictedTextChallengers []string `json:"restrictedTextChallengers"` + CodecCandidates []string `json:"codecCandidates"` +} + +type selectionMetric struct { + MetricID string `json:"metricId"` + Stage string `json:"stage"` + Role string `json:"role"` + Direction string `json:"direction"` + Baseline string `json:"baseline"` + Aggregation string `json:"aggregation"` + Requirement string `json:"requirement"` + Missing string `json:"missing"` + MaterialThreshold int `json:"materialThreshold"` +} + +type replacementPolicy struct { + MinimumByteImprovementBasisPoints int `json:"minimumByteImprovementBasisPoints"` + MinimumTokenImprovementBasisPoints int `json:"minimumTokenImprovementBasisPoints"` + MaximumAggregateDiffRegressionBasisPoints int `json:"maximumAggregateDiffRegressionBasisPoints"` + MaximumPerEditDiffRegressionBasisPoints int `json:"maximumPerEditDiffRegressionBasisPoints"` + MinimumReviewAccuracyBasisPoints int `json:"minimumReviewAccuracyBasisPoints"` + MaximumInvalidMutationFalseAccepts int `json:"maximumInvalidMutationFalseAccepts"` + MaximumProjectedProductionCostBasisPoints int `json:"maximumProjectedProductionCostBasisPoints"` +} + +type screenObservation struct { + CandidateID string `json:"candidateId"` + FieldClosure string `json:"fieldClosure"` + ReviewAccuracyBasisPoints *int `json:"reviewAccuracyBasisPoints"` + InvalidMutationFalseAccepts *int `json:"invalidMutationFalseAccepts"` + EditLocality bool `json:"editLocality"` + WeightedCanonicalBytes int `json:"weightedCanonicalBytes"` + WeightedTokensO200kBase int `json:"weightedTokensO200kBase"` + ChangedLines int `json:"changedLines"` + ChangedBytes int `json:"changedBytes"` + ProjectedProductionLOC int `json:"projectedProductionLoc"` + ProjectedProductionBranches int `json:"projectedProductionBranches"` + AggregateDiffState string `json:"aggregateDiffState"` + PerEditDiffState string `json:"perEditDiffState"` + ParseTimeState string `json:"parseTimeState"` + FormatTimeState string `json:"formatTimeState"` + LowerCostDominanceState string `json:"lowerCostDominanceState"` +} + +type selectionDecision struct { + State string `json:"state"` + SelectedJSONLayout string `json:"selectedJsonLayout"` + SelectedChallenger *string `json:"selectedChallenger"` + SelectedCodec string `json:"selectedCodec"` + ProductionGrammarCount int `json:"productionGrammarCount"` +} + +func TestSelectionRecordIsClosedAndDecisionIsReproducible(t *testing.T) { + record := readCodecSelection(t) + if record.SchemaVersion != 1 || record.Kind != "proofkit.requirement-source-codec-selection" || record.ScreenEvidence.ScreenManifestVersion != 3 { + t.Fatalf("selection identity = %#v", record) + } + assertScreenDigests(t, record.ScreenEvidence) + assertSortedUniqueMetricRegistry(t, record.MetricRegistry) + assertReplacementPolicy(t, record.ReplacementPolicy) + assertDisjointRoles(t, record.Roles) + assertExactRoles(t, record.Roles) + assertObservationClosure(t, record) + selectedJSON := selectJSONLayout(record) + if selectedJSON != record.Decision.SelectedJSONLayout || selectedJSON != "json-hybrid-v1" { + t.Fatalf("selected JSON = %q, record = %q", selectedJSON, record.Decision.SelectedJSONLayout) + } + if challengerEligible(record) || record.Decision.SelectedChallenger != nil { + t.Fatal("restricted-text challenger was admitted without passing its screen") + } + if record.Decision.State != "grouped_json_only" || record.Decision.SelectedCodec != "grouped-json-v1" || record.Decision.ProductionGrammarCount != 1 { + t.Fatalf("selection decision = %#v", record.Decision) + } + if !reflect.DeepEqual(record.Roles.CodecCandidates, []string{"grouped-json-v1"}) { + t.Fatalf("production codec candidates = %v", record.Roles.CodecCandidates) + } + assertHardGateSelectorsExact(t, record.HardGateSelectors) + assertTestSelectorsExist(t, record.HardGateSelectors) +} + +func readCodecSelection(t *testing.T) codecSelection { + t.Helper() + payload, err := os.ReadFile("testdata/codec-selection.v1.json") + if err != nil { + t.Fatal(err) + } + record, err := admission.DecodeTypedJSON[codecSelection](bytes.NewReader(payload), int64(len(payload))) + if err != nil { + t.Fatal(err) + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + var strict codecSelection + if err := decoder.Decode(&strict); err != nil { + t.Fatal(err) + } + return record +} + +func assertScreenDigests(t *testing.T, value screenEvidence) { + t.Helper() + digests := []string{ + value.ScreenManifestSHA256, value.SelectionOpeningSHA256, value.FixtureIndexSHA256, + value.ObservationsSHA256, value.IndependentValidationSHA256, value.ReviewResultsSHA256, + } + for _, digest := range value.TokenProducerDigests { + digests = append(digests, digest) + } + for _, digest := range digests { + if _, err := admit.LowercaseSHA256(digest, "digest"); err != nil { + t.Fatalf("invalid screen digest %q", digest) + } + } + wantProducers := []string{"openai-tiktoken-0.14.0", "tiktoken-go-0.8.1"} + actualProducers := make([]string, 0, len(value.TokenProducerDigests)) + for producer := range value.TokenProducerDigests { + actualProducers = append(actualProducers, producer) + } + sort.Strings(actualProducers) + if !reflect.DeepEqual(actualProducers, wantProducers) { + t.Fatalf("token producers = %v, want %v", actualProducers, wantProducers) + } +} + +func assertSortedUniqueMetricRegistry(t *testing.T, metrics []selectionMetric) { + t.Helper() + ids := make([]string, len(metrics)) + for index, metric := range metrics { + ids[index] = metric.MetricID + if metric.MetricID == "" || metric.Stage == "" || metric.Role == "" || metric.Direction == "" || metric.Baseline == "" || metric.Aggregation == "" || metric.Requirement == "" || metric.Missing == "" || metric.MaterialThreshold < 0 { + t.Fatalf("incomplete metric row %#v", metric) + } + } + want := append([]string(nil), ids...) + sort.Strings(want) + if !reflect.DeepEqual(ids, want) { + t.Fatalf("metric IDs = %v, want sorted unique %v", ids, want) + } + for index := 1; index < len(ids); index++ { + if ids[index-1] == ids[index] { + t.Fatalf("duplicate metric %q", ids[index]) + } + } + wantIDs := []string{ + "changed_bytes", "changed_lines", "edit_locality", "field_closure", "format_time", + "invalid_mutation_false_accepts", "parse_time", "projected_production_branches", + "projected_production_loc", "review_accuracy_basis_points", "weighted_canonical_bytes", + "weighted_tokens_o200k_base", + } + if !reflect.DeepEqual(ids, wantIDs) { + t.Fatalf("metric IDs = %v, want exact registry %v", ids, wantIDs) + } + for _, metric := range metrics { + if metric.MetricID == "parse_time" || metric.MetricID == "format_time" { + if metric.Stage != "replacement" || metric.Role != "primary" || metric.Direction != "minimize" || metric.Missing != "reject" || metric.Requirement != "noninferior" { + t.Fatalf("replacement performance metric = %#v", metric) + } + } else if metric.Stage != "screen" { + t.Fatalf("screen metric %q has stage %q", metric.MetricID, metric.Stage) + } + } +} + +func assertReplacementPolicy(t *testing.T, policy replacementPolicy) { + t.Helper() + want := replacementPolicy{ + MinimumByteImprovementBasisPoints: 1000, + MinimumTokenImprovementBasisPoints: 1000, + MaximumAggregateDiffRegressionBasisPoints: 500, + MaximumPerEditDiffRegressionBasisPoints: 1500, + MinimumReviewAccuracyBasisPoints: 10000, + MaximumInvalidMutationFalseAccepts: 0, + MaximumProjectedProductionCostBasisPoints: 15000, + } + if policy != want { + t.Fatalf("replacement policy = %#v, want %#v", policy, want) + } +} + +func assertDisjointRoles(t *testing.T, roles selectionRoles) { + t.Helper() + sets := [][]string{ + roles.StatusQuoComparators, roles.ModelAblations, roles.ScreenOnlyComparators, + roles.JSONLayouts, roles.RestrictedTextChallengers, roles.CodecCandidates, + } + seen := map[string]struct{}{} + for _, values := range sets { + if len(values) == 0 || !sort.StringsAreSorted(values) { + t.Fatalf("role set is empty or unsorted: %v", values) + } + for _, value := range values { + if _, exists := seen[value]; exists { + t.Fatalf("candidate %q has multiple roles", value) + } + seen[value] = struct{}{} + } + } +} + +func assertExactRoles(t *testing.T, roles selectionRoles) { + t.Helper() + want := selectionRoles{ + StatusQuoComparators: []string{"flat-v1"}, + ModelAblations: []string{ + "grouped-profile-off-stem-off", "grouped-profile-off-stem-on", + "grouped-profile-on-stem-off", "grouped-profile-on-stem-on", + }, + ScreenOnlyComparators: []string{"toml-tristate-v1", "yaml-strict-v1"}, + JSONLayouts: []string{"json-compact-v1", "json-hybrid-v1", "json-pretty-v1"}, + RestrictedTextChallengers: []string{"proofkit-source-text-v1"}, + CodecCandidates: []string{"grouped-json-v1"}, + } + if !reflect.DeepEqual(roles, want) { + t.Fatalf("selection roles = %#v, want %#v", roles, want) + } +} + +func assertObservationClosure(t *testing.T, record codecSelection) { + t.Helper() + want := append([]string(nil), record.Roles.JSONLayouts...) + want = append(want, record.Roles.ScreenOnlyComparators...) + want = append(want, record.Roles.RestrictedTextChallengers...) + sort.Strings(want) + actual := make([]string, len(record.ScreenObservations)) + allowedState := map[string]bool{"failed": true, "missing": true, "not_applicable": true, "passed": true} + challengers := stringSet(record.Roles.RestrictedTextChallengers) + for index, observation := range record.ScreenObservations { + actual[index] = observation.CandidateID + if observation.CandidateID == "" || observation.WeightedCanonicalBytes <= 0 || observation.WeightedTokensO200kBase <= 0 || + observation.ChangedLines < 0 || observation.ChangedBytes < 0 || observation.ProjectedProductionLOC <= 0 || observation.ProjectedProductionBranches <= 0 { + t.Fatalf("candidate %q has invalid quantitative observation", observation.CandidateID) + } + if observation.ReviewAccuracyBasisPoints != nil && (*observation.ReviewAccuracyBasisPoints < 0 || *observation.ReviewAccuracyBasisPoints > 10000) { + t.Fatalf("candidate %q has invalid review accuracy", observation.CandidateID) + } + if observation.InvalidMutationFalseAccepts != nil && *observation.InvalidMutationFalseAccepts < 0 { + t.Fatalf("candidate %q has invalid false-accept count", observation.CandidateID) + } + states := []string{observation.AggregateDiffState, observation.PerEditDiffState, observation.ParseTimeState, observation.FormatTimeState, observation.LowerCostDominanceState} + for _, state := range states { + if !allowedState[state] { + t.Fatalf("candidate %q has invalid replacement state %q", observation.CandidateID, state) + } + } + if _, isChallenger := challengers[observation.CandidateID]; !isChallenger { + for _, state := range states { + if state != "not_applicable" { + t.Fatalf("non-challenger %q has replacement state %q", observation.CandidateID, state) + } + } + } + } + if !reflect.DeepEqual(actual, want) { + t.Fatalf("screen observations = %v, want %v", actual, want) + } +} + +func stringSet(values []string) map[string]struct{} { + result := make(map[string]struct{}, len(values)) + for _, value := range values { + result[value] = struct{}{} + } + return result +} + +func assertHardGateSelectorsExact(t *testing.T, selectors []string) { + t.Helper() + want := []string{ + "TestFieldManifestMatchesWireDTOAndClosedShape", + "TestFormatParseRoundTripPreservesEveryProjection", + "TestCanonicalFormatIsIdempotent", + "TestChallengerEligibilityRequiresEveryReplacementPredicate", + "TestCodecMutantManifestClosesRepresentationFailures", + "TestParseDiagnosticsDoNotDiscloseCallerTextOrDynamicKeys", + "TestRawByteBoundaryIsExactAndDominatesUTF8", + "TestHybridLayoutKeepsStableSiblingEntityLinesUnchanged", + } + if !reflect.DeepEqual(selectors, want) { + t.Fatalf("hard-gate selectors = %v, want %v", selectors, want) + } +} + +func assertTestSelectorsExist(t *testing.T, selectors []string) { + t.Helper() + files, err := filepath.Glob("*_test.go") + if err != nil { + t.Fatal(err) + } + found := map[string]struct{}{} + for _, path := range files { + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + t.Fatal(err) + } + for _, declaration := range parsed.Decls { + function, ok := declaration.(*ast.FuncDecl) + if ok && function.Recv == nil { + found[function.Name.Name] = struct{}{} + } + } + } + for _, selector := range selectors { + if _, exists := found[selector]; !exists { + t.Fatalf("hard-gate selector %q does not exist", selector) + } + } +} diff --git a/internal/kernel/requirementsourcecodec/shape.go b/internal/kernel/requirementsourcecodec/shape.go new file mode 100644 index 0000000..da07cda --- /dev/null +++ b/internal/kernel/requirementsourcecodec/shape.go @@ -0,0 +1,262 @@ +package requirementsourcecodec + +import ( + "encoding/json" + "sort" + "strconv" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +type shapeKind uint8 + +const ( + shapeObject shapeKind = iota + 1 + shapeArray + shapeString + shapeInteger + shapeBoolean +) + +type shapeField struct { + shape *shape + required bool +} + +type shape struct { + kind shapeKind + fields map[string]shapeField + dynamic *shape + element *shape + nullable bool + maxItems int + exactString string + exactInt *int64 +} + +func documentShape(limits requirementsourcemodel.Limits) *shape { + stringValue := scalar(shapeString) + booleanValue := scalar(shapeBoolean) + integerValue := scalar(shapeInteger) + stringsValue := array(stringValue, limits.MaxCollectionItems) + lifecycleValue := object(requiredFields(map[string]*shape{ + "state": stringValue, "replacementRequirementIds": stringsValue, "evidenceRefs": stringsValue, + })) + deferralValue := object(requiredFields(map[string]*shape{ + "ownerId": stringValue, "riskAcceptedBy": stringValue, "reviewCondition": stringValue, + "expiryRef": stringValue, "mergePolicy": stringValue, "evidenceRefs": stringsValue, + })) + deferralValue.nullable = true + updatePolicyValue := object(requiredFields(map[string]*shape{ + "reviewOwnerId": stringValue, "requiresImpactDeclaration": booleanValue, "requiresProofBindingReview": booleanValue, + })) + metadataValue := object(map[string]shapeField{ + "ownerId": {shape: stringValue}, "claimLevel": {shape: stringValue}, "riskClass": {shape: stringValue}, + "nonClaimRefs": {shape: stringsValue}, "lifecycle": {shape: lifecycleValue}, "deferral": {shape: deferralValue}, "updatePolicy": {shape: updatePolicyValue}, + }) + nonClaimValue := object(requiredFields(map[string]*shape{"nonClaimId": stringValue, "statement": stringValue})) + termValue := object(requiredFields(map[string]*shape{"termId": stringValue, "kind": stringValue, "label": stringValue, "definition": stringValue})) + gitRefValue := object(requiredFields(map[string]*shape{"objectFormat": stringValue, "commitOid": stringValue, "path": stringValue, "sha256": stringValue})) + rangeValue := object(requiredFields(map[string]*shape{"start": integerValue, "end": integerValue})) + derivationValue := object(requiredFields(map[string]*shape{ + "derivationId": stringValue, "sourceKind": stringValue, "sourceRef": gitRefValue, "selector": rangeValue, + "requirementIds": stringsValue, "nonClaimRefs": stringsValue, + })) + profileValue := object(requiredFields(map[string]*shape{"profileId": stringValue, "fields": metadataValue})) + memberValue := object(requiredFields(map[string]*shape{"requirementId": stringValue, "statementCompletion": stringValue, "fields": metadataValue})) + groupValue := object(requiredFields(map[string]*shape{ + "groupId": stringValue, "profileId": stringValue, "statementStem": stringValue, "sharedPremises": stringsValue, + "members": array(memberValue, limits.MaxMembersPerGroup), + })) + valuesValue := &shape{kind: shapeObject, dynamic: stringValue, maxItems: limits.MaxCollectionItems} + exampleValue := object(requiredFields(map[string]*shape{"exampleId": stringValue, "values": valuesValue})) + scenarioValue := object(requiredFields(map[string]*shape{ + "scenarioId": stringValue, "requirementIds": stringsValue, "parameters": stringsValue, + "preconditions": stringsValue, "actionSequence": stringsValue, "expectedObservations": stringsValue, + "forbiddenObservations": stringsValue, "examples": array(exampleValue, limits.MaxExamplesPerScenario), + "vocabularyRefs": stringsValue, "nonClaimRefs": stringsValue, + })) + version := int64(SchemaVersion) + versionShape := scalar(shapeInteger) + versionShape.exactInt = &version + kindShape := scalar(shapeString) + kindShape.exactString = DocumentKind + return object(requiredFields(map[string]*shape{ + "schemaVersion": versionShape, + "kind": kindShape, + "sourceId": stringValue, + "specPackagePath": stringValue, + "sourceNonClaimRefs": stringsValue, + "nonClaimDefinitions": array(nonClaimValue, limits.MaxDefinitions), + "vocabulary": array(termValue, limits.MaxTerms), + "derivations": array(derivationValue, limits.MaxDerivations), + "profiles": array(profileValue, limits.MaxProfiles), + "groups": array(groupValue, limits.MaxGroups), + "scenarios": array(scenarioValue, limits.MaxScenarios), + })) +} + +func validateShape(value any, expected *shape, path string, locations map[string]rawLocation, source []byte) error { + if value == nil { + if expected.nullable { + return nil + } + return shapeError(source, locations, "invalid_null", path) + } + switch expected.kind { + case shapeObject: + record, ok := value.(map[string]any) + if !ok { + return shapeError(source, locations, "invalid_type", path) + } + if expected.maxItems > 0 && len(record) > expected.maxItems { + return shapeError(source, locations, "collection_limit_exceeded", path) + } + if expected.dynamic != nil { + for _, key := range orderedRecordKeys(record, path, locations) { + childPath := joinPointer(path, key) + if _, ok := record[key].(string); !ok { + return shapeErrorAt(source, locations, "invalid_type", joinPointer(path, ""), childPath) + } + } + return nil + } + for _, key := range orderedRecordKeys(record, path, locations) { + field, exists := expected.fields[key] + if !exists { + for canonical := range expected.fields { + if strings.EqualFold(key, canonical) { + return shapeErrorAt(source, locations, "noncanonical_field", joinPointer(path, canonical), joinPointer(path, key)) + } + } + return shapeErrorAt(source, locations, "unknown_field", joinPointer(path, ""), joinPointer(path, key)) + } + if err := validateShape(record[key], field.shape, joinPointer(path, key), locations, source); err != nil { + return err + } + } + for _, key := range sortedShapeFieldKeys(expected.fields) { + field := expected.fields[key] + if field.required { + if _, exists := record[key]; !exists { + return shapeError(source, locations, "missing_field", joinPointer(path, key)) + } + } + } + case shapeArray: + values, ok := value.([]any) + if !ok { + return shapeError(source, locations, "invalid_type", path) + } + if len(values) > expected.maxItems { + return shapeError(source, locations, "collection_limit_exceeded", path) + } + for index, child := range values { + if err := validateShape(child, expected.element, joinPointer(path, strconv.Itoa(index)), locations, source); err != nil { + return err + } + } + case shapeString: + text, ok := value.(string) + if !ok { + return shapeError(source, locations, "invalid_type", path) + } + if expected.exactString != "" && text != expected.exactString { + return shapeError(source, locations, "invalid_identity", path) + } + case shapeInteger: + number, ok := value.(json.Number) + if !ok { + return shapeError(source, locations, "invalid_type", path) + } + integer, err := strconv.ParseInt(string(number), 10, 64) + if err != nil || strconv.FormatInt(integer, 10) != string(number) { + return shapeError(source, locations, "invalid_integer", path) + } + if expected.exactInt != nil && integer != *expected.exactInt { + return shapeError(source, locations, "invalid_identity", path) + } + case shapeBoolean: + if _, ok := value.(bool); !ok { + return shapeError(source, locations, "invalid_type", path) + } + default: + return shapeError(source, locations, "invalid_shape", path) + } + return nil +} + +func shapeError(source []byte, locations map[string]rawLocation, code string, path string) error { + return shapeErrorAt(source, locations, code, path, path) +} + +func shapeErrorAt(source []byte, locations map[string]rawLocation, code string, path string, locationPath string) error { + location, exists := locations[locationPath] + if !exists { + parent := locationPath + for parent != "" { + index := strings.LastIndex(parent, "/") + if index < 0 { + break + } + parent = parent[:index] + if location, exists = locations[parent]; exists { + break + } + } + } + span := location.value + if location.key != nil && (code == "unknown_field" || code == "noncanonical_field") { + span = *location.key + } + return diagnosticError(source, code, path, span, true) +} + +func orderedRecordKeys(record map[string]any, path string, locations map[string]rawLocation) []string { + keys := make([]string, 0, len(record)) + for key := range record { + keys = append(keys, key) + } + sort.Slice(keys, func(left, right int) bool { + leftLocation, leftExists := locations[joinPointer(path, keys[left])] + rightLocation, rightExists := locations[joinPointer(path, keys[right])] + if leftExists != rightExists { + return leftExists + } + if leftExists && leftLocation.key != nil && rightLocation.key != nil && leftLocation.key.Start != rightLocation.key.Start { + return leftLocation.key.Start < rightLocation.key.Start + } + return keys[left] < keys[right] + }) + return keys +} + +func sortedShapeFieldKeys(fields map[string]shapeField) []string { + keys := make([]string, 0, len(fields)) + for key := range fields { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func requiredFields(values map[string]*shape) map[string]shapeField { + result := make(map[string]shapeField, len(values)) + for key, value := range values { + result[key] = shapeField{shape: value, required: true} + } + return result +} + +func object(fields map[string]shapeField) *shape { + return &shape{kind: shapeObject, fields: fields} +} + +func array(element *shape, maxItems int) *shape { + return &shape{kind: shapeArray, element: element, maxItems: maxItems} +} + +func scalar(kind shapeKind) *shape { + return &shape{kind: kind} +} diff --git a/internal/kernel/requirementsourcecodec/testdata/codec-field-manifest.v1.json b/internal/kernel/requirementsourcecodec/testdata/codec-field-manifest.v1.json new file mode 100644 index 0000000..2daeef4 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/testdata/codec-field-manifest.v1.json @@ -0,0 +1,152 @@ +{ + "schemaVersion": 1, + "kind": "proofkit.requirement-source-codec-field-manifest", + "rootRecordId": "document", + "records": [ + { + "recordId": "byteRange", + "fields": [ + {"name": "start", "type": "integer", "required": true, "nullable": false}, + {"name": "end", "type": "integer", "required": true, "nullable": false} + ] + }, + { + "recordId": "deferral", + "fields": [ + {"name": "ownerId", "type": "string", "required": true, "nullable": false}, + {"name": "riskAcceptedBy", "type": "string", "required": true, "nullable": false}, + {"name": "reviewCondition", "type": "string", "required": true, "nullable": false}, + {"name": "expiryRef", "type": "string", "required": true, "nullable": false}, + {"name": "mergePolicy", "type": "string", "required": true, "nullable": false}, + {"name": "evidenceRefs", "type": "array:string", "required": true, "nullable": false} + ] + }, + { + "recordId": "derivation", + "fields": [ + {"name": "derivationId", "type": "string", "required": true, "nullable": false}, + {"name": "sourceKind", "type": "string", "required": true, "nullable": false}, + {"name": "sourceRef", "type": "record:gitBlobRef", "required": true, "nullable": false}, + {"name": "selector", "type": "record:byteRange", "required": true, "nullable": false}, + {"name": "requirementIds", "type": "array:string", "required": true, "nullable": false}, + {"name": "nonClaimRefs", "type": "array:string", "required": true, "nullable": false} + ] + }, + { + "recordId": "document", + "fields": [ + {"name": "schemaVersion", "type": "integer", "required": true, "nullable": false, "constant": "2"}, + {"name": "kind", "type": "string", "required": true, "nullable": false, "constant": "proofkit.requirement-source"}, + {"name": "sourceId", "type": "string", "required": true, "nullable": false}, + {"name": "specPackagePath", "type": "string", "required": true, "nullable": false}, + {"name": "sourceNonClaimRefs", "type": "array:string", "required": true, "nullable": false}, + {"name": "nonClaimDefinitions", "type": "array:record:nonClaimDefinition", "required": true, "nullable": false}, + {"name": "vocabulary", "type": "array:record:vocabularyTerm", "required": true, "nullable": false}, + {"name": "derivations", "type": "array:record:derivation", "required": true, "nullable": false}, + {"name": "profiles", "type": "array:record:profile", "required": true, "nullable": false}, + {"name": "groups", "type": "array:record:group", "required": true, "nullable": false}, + {"name": "scenarios", "type": "array:record:scenario", "required": true, "nullable": false} + ] + }, + { + "recordId": "example", + "fields": [ + {"name": "exampleId", "type": "string", "required": true, "nullable": false}, + {"name": "values", "type": "map:string", "required": true, "nullable": false} + ] + }, + { + "recordId": "gitBlobRef", + "fields": [ + {"name": "objectFormat", "type": "string", "required": true, "nullable": false}, + {"name": "commitOid", "type": "string", "required": true, "nullable": false}, + {"name": "path", "type": "string", "required": true, "nullable": false}, + {"name": "sha256", "type": "string", "required": true, "nullable": false} + ] + }, + { + "recordId": "group", + "fields": [ + {"name": "groupId", "type": "string", "required": true, "nullable": false}, + {"name": "profileId", "type": "string", "required": true, "nullable": false}, + {"name": "statementStem", "type": "string", "required": true, "nullable": false}, + {"name": "sharedPremises", "type": "array:string", "required": true, "nullable": false}, + {"name": "members", "type": "array:record:member", "required": true, "nullable": false} + ] + }, + { + "recordId": "lifecycle", + "fields": [ + {"name": "state", "type": "string", "required": true, "nullable": false}, + {"name": "replacementRequirementIds", "type": "array:string", "required": true, "nullable": false}, + {"name": "evidenceRefs", "type": "array:string", "required": true, "nullable": false} + ] + }, + { + "recordId": "member", + "fields": [ + {"name": "requirementId", "type": "string", "required": true, "nullable": false}, + {"name": "statementCompletion", "type": "string", "required": true, "nullable": false}, + {"name": "fields", "type": "record:metadataFields", "required": true, "nullable": false} + ] + }, + { + "recordId": "metadataFields", + "fields": [ + {"name": "ownerId", "type": "string", "required": false, "nullable": false}, + {"name": "claimLevel", "type": "string", "required": false, "nullable": false}, + {"name": "riskClass", "type": "string", "required": false, "nullable": false}, + {"name": "nonClaimRefs", "type": "array:string", "required": false, "nullable": false}, + {"name": "lifecycle", "type": "record:lifecycle", "required": false, "nullable": false}, + {"name": "deferral", "type": "record:deferral", "required": false, "nullable": true}, + {"name": "updatePolicy", "type": "record:updatePolicy", "required": false, "nullable": false} + ] + }, + { + "recordId": "nonClaimDefinition", + "fields": [ + {"name": "nonClaimId", "type": "string", "required": true, "nullable": false}, + {"name": "statement", "type": "string", "required": true, "nullable": false} + ] + }, + { + "recordId": "profile", + "fields": [ + {"name": "profileId", "type": "string", "required": true, "nullable": false}, + {"name": "fields", "type": "record:metadataFields", "required": true, "nullable": false} + ] + }, + { + "recordId": "scenario", + "fields": [ + {"name": "scenarioId", "type": "string", "required": true, "nullable": false}, + {"name": "requirementIds", "type": "array:string", "required": true, "nullable": false}, + {"name": "parameters", "type": "array:string", "required": true, "nullable": false}, + {"name": "preconditions", "type": "array:string", "required": true, "nullable": false}, + {"name": "actionSequence", "type": "array:string", "required": true, "nullable": false}, + {"name": "expectedObservations", "type": "array:string", "required": true, "nullable": false}, + {"name": "forbiddenObservations", "type": "array:string", "required": true, "nullable": false}, + {"name": "examples", "type": "array:record:example", "required": true, "nullable": false}, + {"name": "vocabularyRefs", "type": "array:string", "required": true, "nullable": false}, + {"name": "nonClaimRefs", "type": "array:string", "required": true, "nullable": false} + ] + }, + { + "recordId": "updatePolicy", + "fields": [ + {"name": "reviewOwnerId", "type": "string", "required": true, "nullable": false}, + {"name": "requiresImpactDeclaration", "type": "boolean", "required": true, "nullable": false}, + {"name": "requiresProofBindingReview", "type": "boolean", "required": true, "nullable": false} + ] + }, + { + "recordId": "vocabularyTerm", + "fields": [ + {"name": "termId", "type": "string", "required": true, "nullable": false}, + {"name": "kind", "type": "string", "required": true, "nullable": false}, + {"name": "label", "type": "string", "required": true, "nullable": false}, + {"name": "definition", "type": "string", "required": true, "nullable": false} + ] + } + ] +} diff --git a/internal/kernel/requirementsourcecodec/testdata/codec-limit-coefficients.v1.json b/internal/kernel/requirementsourcecodec/testdata/codec-limit-coefficients.v1.json new file mode 100644 index 0000000..9ba9277 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/testdata/codec-limit-coefficients.v1.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "kind": "proofkit.requirement-source-codec-limit-coefficients", + "canonicalByteBaseOverhead": 4096, + "lexicalTokenBaseOverhead": 1024, + "minimumJsonNesting": 7, + "maximumJsonNesting": 64, + "canonicalByteCoefficients": [ + {"id": "collection_items", "coefficient": 32}, + {"id": "definitions", "coefficient": 96}, + {"id": "derivations", "coefficient": 384}, + {"id": "examples", "coefficient": 256}, + {"id": "groups", "coefficient": 320}, + {"id": "members", "coefficient": 768}, + {"id": "profiles", "coefficient": 448}, + {"id": "scenarios", "coefficient": 896}, + {"id": "terms", "coefficient": 160}, + {"id": "total_text_bytes", "coefficient": 3} + ], + "lexicalTokenCoefficients": [ + {"id": "collection_items", "coefficient": 32}, + {"id": "definitions", "coefficient": 32}, + {"id": "derivations", "coefficient": 32}, + {"id": "examples", "coefficient": 32}, + {"id": "groups", "coefficient": 32}, + {"id": "members", "coefficient": 32}, + {"id": "profiles", "coefficient": 32}, + {"id": "scenarios", "coefficient": 32}, + {"id": "terms", "coefficient": 32} + ] +} diff --git a/internal/kernel/requirementsourcecodec/testdata/codec-mutants.v1.json b/internal/kernel/requirementsourcecodec/testdata/codec-mutants.v1.json new file mode 100644 index 0000000..8ea7ade --- /dev/null +++ b/internal/kernel/requirementsourcecodec/testdata/codec-mutants.v1.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "kind": "proofkit.requirement-source-codec-mutants", + "mutants": [ + {"mutantId": "case_folded_field", "layer": "shape", "expectedCode": "noncanonical_field", "expectedPath": "/sourceId", "property": "exact-field-spelling"}, + {"mutantId": "duplicate_field", "layer": "lexical", "expectedCode": "duplicate_field", "expectedPath": "", "property": "unique-object-fields"}, + {"mutantId": "dynamic_key_wrong_type", "layer": "shape", "expectedCode": "invalid_type", "expectedPath": "/scenarios/0/examples/0/values/", "property": "dynamic-value-type-and-key-nondisclosure"}, + {"mutantId": "explicit_null", "layer": "shape", "expectedCode": "invalid_null", "expectedPath": "/sourceId", "property": "nullability-closure"}, + {"mutantId": "fractional_integer", "layer": "shape", "expectedCode": "invalid_integer", "expectedPath": "/schemaVersion", "property": "integer-lexeme"}, + {"mutantId": "invalid_utf8", "layer": "raw", "expectedCode": "invalid_utf8", "expectedPath": "", "property": "utf8-validity"}, + {"mutantId": "lone_surrogate", "layer": "lexical", "expectedCode": "invalid_unicode_escape", "expectedPath": "/nonClaimDefinitions/0/statement", "property": "unicode-scalar-losslessness"}, + {"mutantId": "missing_required", "layer": "shape", "expectedCode": "missing_field", "expectedPath": "/sourceId", "property": "required-field-closure"}, + {"mutantId": "multiple_values", "layer": "syntax", "expectedCode": "multiple_values", "expectedPath": "", "property": "single-document-closure"}, + {"mutantId": "negative_zero", "layer": "shape", "expectedCode": "invalid_integer", "expectedPath": "/derivations/0/selector/start", "property": "integer-lexeme"}, + {"mutantId": "secret_shaped_text", "layer": "model", "expectedCode": "invalid_text", "expectedPath": "/nonClaimDefinitions/0/statement", "property": "report-visible-nondisclosure"}, + {"mutantId": "semantic_duplicate_id", "layer": "model", "expectedCode": "duplicate_id", "expectedPath": "/nonClaimDefinitions", "property": "semantic-owner-delegation"}, + {"mutantId": "unknown_field", "layer": "shape", "expectedCode": "unknown_field", "expectedPath": "/", "property": "closed-record-fields"}, + {"mutantId": "wrong_identity", "layer": "shape", "expectedCode": "invalid_identity", "expectedPath": "/schemaVersion", "property": "representation-identity"}, + {"mutantId": "wrong_type", "layer": "shape", "expectedCode": "invalid_type", "expectedPath": "/sourceId", "property": "field-type-closure"} + ] +} diff --git a/internal/kernel/requirementsourcecodec/testdata/codec-selection.v1.json b/internal/kernel/requirementsourcecodec/testdata/codec-selection.v1.json new file mode 100644 index 0000000..d2a7f19 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/testdata/codec-selection.v1.json @@ -0,0 +1,86 @@ +{ + "schemaVersion": 1, + "kind": "proofkit.requirement-source-codec-selection", + "screenEvidence": { + "screenManifestVersion": 3, + "screenManifestSha256": "80de3139937b864076254a7cca36820bdf54bef7c06e4aad1091d77910e643ea", + "selectionOpeningSha256": "72306508677a38683186e965cf6efc21aba080571d89b4d9c8cb4e65a2162347", + "fixtureIndexSha256": "505bb896082805708975bbc698e2414f625cb85bb1272512483c8c263efb54e0", + "observationsSha256": "0abd85c4dfaf3f8a3cdd1bad366e1961d5e57ed47a8bd14321a48f308ae66b7d", + "independentValidationSha256": "f565439bc2d8001a67f37cca5125bf6d1b70c4ba6cf1cb3f038b1fb1b2c670b2", + "reviewResultsSha256": "3e632343ef36f27f472ecf7560d2b75f7cad4a35c15678ce7eb078b277512b63", + "tokenProducerDigests": { + "openai-tiktoken-0.14.0": "e19d6985ad9599c86bbd672aef93dfed4e1643e48282e0e1ee91a388721e55db", + "tiktoken-go-0.8.1": "7c185ae59f0551c86fb2f30fa0ea338103baa278e267cafb0e43e6205f0fa4eb" + } + }, + "roles": { + "statusQuoComparators": ["flat-v1"], + "modelAblations": [ + "grouped-profile-off-stem-off", + "grouped-profile-off-stem-on", + "grouped-profile-on-stem-off", + "grouped-profile-on-stem-on" + ], + "screenOnlyComparators": ["toml-tristate-v1", "yaml-strict-v1"], + "jsonLayouts": ["json-compact-v1", "json-hybrid-v1", "json-pretty-v1"], + "restrictedTextChallengers": ["proofkit-source-text-v1"], + "codecCandidates": ["grouped-json-v1"] + }, + "metricRegistry": [ + {"metricId": "changed_bytes", "stage": "screen", "role": "primary", "direction": "minimize", "baseline": "eligible-json-layouts", "aggregation": "sum-over-frozen-edits", "requirement": "lexicographic-minimum", "missing": "reject", "materialThreshold": 0}, + {"metricId": "changed_lines", "stage": "screen", "role": "primary", "direction": "minimize", "baseline": "eligible-json-layouts", "aggregation": "sum-over-frozen-edits", "requirement": "lexicographic-minimum", "missing": "reject", "materialThreshold": 0}, + {"metricId": "edit_locality", "stage": "screen", "role": "hard", "direction": "equal", "baseline": "affected-entity-registry", "aggregation": "all-frozen-edits", "requirement": "true", "missing": "fail", "materialThreshold": 0}, + {"metricId": "field_closure", "stage": "screen", "role": "hard", "direction": "equal", "baseline": "codec-field-manifest-v1", "aggregation": "all-fields", "requirement": "passed", "missing": "fail", "materialThreshold": 0}, + {"metricId": "format_time", "stage": "replacement", "role": "primary", "direction": "minimize", "baseline": "selected-grouped-json-v1", "aggregation": "paired-randomized-confidence-bound", "requirement": "noninferior", "missing": "reject", "materialThreshold": 0}, + {"metricId": "invalid_mutation_false_accepts", "stage": "screen", "role": "hard", "direction": "minimize", "baseline": "frozen-invalid-review-task", "aggregation": "sum", "requirement": "zero", "missing": "fail", "materialThreshold": 0}, + {"metricId": "parse_time", "stage": "replacement", "role": "primary", "direction": "minimize", "baseline": "selected-grouped-json-v1", "aggregation": "paired-randomized-confidence-bound", "requirement": "noninferior", "missing": "reject", "materialThreshold": 0}, + {"metricId": "projected_production_branches", "stage": "screen", "role": "report_only", "direction": "minimize", "baseline": "selected-json-layout", "aggregation": "estimate", "requirement": "none", "missing": "unknown", "materialThreshold": 0}, + {"metricId": "projected_production_loc", "stage": "screen", "role": "report_only", "direction": "minimize", "baseline": "selected-json-layout", "aggregation": "estimate", "requirement": "none", "missing": "unknown", "materialThreshold": 0}, + {"metricId": "review_accuracy_basis_points", "stage": "screen", "role": "hard", "direction": "maximize", "baseline": "maximum-observed-json-layout", "aggregation": "exact-gold-answers", "requirement": "equal-to-maximum", "missing": "fail", "materialThreshold": 0}, + {"metricId": "weighted_canonical_bytes", "stage": "screen", "role": "primary", "direction": "minimize", "baseline": "eligible-json-layouts", "aggregation": "weighted-sum", "requirement": "lexicographic-minimum", "missing": "reject", "materialThreshold": 0}, + {"metricId": "weighted_tokens_o200k_base", "stage": "screen", "role": "primary", "direction": "minimize", "baseline": "eligible-json-layouts", "aggregation": "weighted-sum", "requirement": "lexicographic-minimum", "missing": "reject", "materialThreshold": 0} + ], + "replacementPolicy": { + "minimumByteImprovementBasisPoints": 1000, + "minimumTokenImprovementBasisPoints": 1000, + "maximumAggregateDiffRegressionBasisPoints": 500, + "maximumPerEditDiffRegressionBasisPoints": 1500, + "minimumReviewAccuracyBasisPoints": 10000, + "maximumInvalidMutationFalseAccepts": 0, + "maximumProjectedProductionCostBasisPoints": 15000 + }, + "screenObservations": [ + {"candidateId": "json-compact-v1", "fieldClosure": "passed", "reviewAccuracyBasisPoints": 10000, "invalidMutationFalseAccepts": 0, "editLocality": false, "weightedCanonicalBytes": 2458590, "weightedTokensO200kBase": 652930, "changedLines": 16, "changedBytes": 57377, "projectedProductionLoc": 520, "projectedProductionBranches": 44, "aggregateDiffState": "not_applicable", "perEditDiffState": "not_applicable", "parseTimeState": "not_applicable", "formatTimeState": "not_applicable", "lowerCostDominanceState": "not_applicable"}, + {"candidateId": "json-hybrid-v1", "fieldClosure": "passed", "reviewAccuracyBasisPoints": 10000, "invalidMutationFalseAccepts": 0, "editLocality": true, "weightedCanonicalBytes": 2605965, "weightedTokensO200kBase": 698725, "changedLines": 34, "changedBytes": 5721, "projectedProductionLoc": 535, "projectedProductionBranches": 46, "aggregateDiffState": "not_applicable", "perEditDiffState": "not_applicable", "parseTimeState": "not_applicable", "formatTimeState": "not_applicable", "lowerCostDominanceState": "not_applicable"}, + {"candidateId": "json-pretty-v1", "fieldClosure": "passed", "reviewAccuracyBasisPoints": 10000, "invalidMutationFalseAccepts": 0, "editLocality": false, "weightedCanonicalBytes": 4477120, "weightedTokensO200kBase": 1018030, "changedLines": 200, "changedBytes": 6863, "projectedProductionLoc": 520, "projectedProductionBranches": 44, "aggregateDiffState": "not_applicable", "perEditDiffState": "not_applicable", "parseTimeState": "not_applicable", "formatTimeState": "not_applicable", "lowerCostDominanceState": "not_applicable"}, + {"candidateId": "proofkit-source-text-v1", "fieldClosure": "not_applicable", "reviewAccuracyBasisPoints": null, "invalidMutationFalseAccepts": null, "editLocality": false, "weightedCanonicalBytes": 3899810, "weightedTokensO200kBase": 769855, "changedLines": 166, "changedBytes": 6091, "projectedProductionLoc": 910, "projectedProductionBranches": 92, "aggregateDiffState": "failed", "perEditDiffState": "failed", "parseTimeState": "missing", "formatTimeState": "missing", "lowerCostDominanceState": "failed"}, + {"candidateId": "toml-tristate-v1", "fieldClosure": "not_applicable", "reviewAccuracyBasisPoints": null, "invalidMutationFalseAccepts": null, "editLocality": false, "weightedCanonicalBytes": 3167570, "weightedTokensO200kBase": 827095, "changedLines": 154, "changedBytes": 4949, "projectedProductionLoc": 720, "projectedProductionBranches": 65, "aggregateDiffState": "not_applicable", "perEditDiffState": "not_applicable", "parseTimeState": "not_applicable", "formatTimeState": "not_applicable", "lowerCostDominanceState": "not_applicable"}, + {"candidateId": "yaml-strict-v1", "fieldClosure": "not_applicable", "reviewAccuracyBasisPoints": null, "invalidMutationFalseAccepts": null, "editLocality": false, "weightedCanonicalBytes": 3397905, "weightedTokensO200kBase": 783225, "changedLines": 153, "changedBytes": 5685, "projectedProductionLoc": 690, "projectedProductionBranches": 61, "aggregateDiffState": "not_applicable", "perEditDiffState": "not_applicable", "parseTimeState": "not_applicable", "formatTimeState": "not_applicable", "lowerCostDominanceState": "not_applicable"} + ], + "decision": { + "state": "grouped_json_only", + "selectedJsonLayout": "json-hybrid-v1", + "selectedChallenger": null, + "selectedCodec": "grouped-json-v1", + "productionGrammarCount": 1 + }, + "hardGateSelectors": [ + "TestFieldManifestMatchesWireDTOAndClosedShape", + "TestFormatParseRoundTripPreservesEveryProjection", + "TestCanonicalFormatIsIdempotent", + "TestChallengerEligibilityRequiresEveryReplacementPredicate", + "TestCodecMutantManifestClosesRepresentationFailures", + "TestParseDiagnosticsDoNotDiscloseCallerTextOrDynamicKeys", + "TestRawByteBoundaryIsExactAndDominatesUTF8", + "TestHybridLayoutKeepsStableSiblingEntityLinesUnchanged" + ], + "nonClaims": [ + "The screen does not prove corpus-external review or token outcomes.", + "The codec selection does not re-prove grouped-model causal benefits owned by REQ-PROOFKIT-SPEC-024.", + "No restricted-text challenger reached the replacement stage; missing parse and format comparisons therefore reject replacement rather than proving performance parity.", + "Screen-only YAML and TOML renderers are not admitted source codecs.", + "The rejected restricted-text sketch is not a grammar or parser authority.", + "The selected private codec does not migrate current sources or alter the public CLI." + ] +} diff --git a/internal/kernel/requirementsourcecodec/types.go b/internal/kernel/requirementsourcecodec/types.go new file mode 100644 index 0000000..9886cd4 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/types.go @@ -0,0 +1,112 @@ +package requirementsourcecodec + +import ( + "fmt" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +const ( + SchemaVersion = 2 + DocumentKind = "proofkit.requirement-source" +) + +type Limits struct { + MaxRawBytes int64 + MaxTokens int + MaxNesting int + MaxOutputBytes int64 +} + +type ByteSpan struct { + Start int64 + End int64 +} + +type Position struct { + Line int + ScalarColumn int +} + +type Location struct { + KeySpan *ByteSpan + ValueSpan ByteSpan + Start Position + End Position +} + +type SourceMap struct { + entries map[string]Location +} + +func (sourceMap SourceMap) Location(pointer string) (Location, bool) { + location, exists := sourceMap.entries[pointer] + return cloneLocation(location), exists +} + +func (sourceMap SourceMap) Pointers() []string { + result := make([]string, 0, len(sourceMap.entries)) + for pointer := range sourceMap.entries { + result = append(result, pointer) + } + sortStrings(result) + return result +} + +type Result struct { + Model requirementsourcemodel.Model + SourceMap SourceMap +} + +type Diagnostic struct { + Code string + Path string + Span ByteSpan + CoordinateState string + Start *Position + End *Position +} + +type Error struct { + diagnostic Diagnostic +} + +func (err *Error) Error() string { + if err.diagnostic.Path == "" { + return err.diagnostic.Code + } + return fmt.Sprintf("%s: %s", err.diagnostic.Code, err.diagnostic.Path) +} + +func (err *Error) Diagnostic() Diagnostic { + return cloneDiagnostic(err.diagnostic) +} + +func ErrorCode(err error) string { + if typed, ok := err.(*Error); ok { + return typed.diagnostic.Code + } + return "" +} + +func cloneLocation(value Location) Location { + result := value + if value.KeySpan != nil { + span := *value.KeySpan + result.KeySpan = &span + } + return result +} + +func cloneDiagnostic(value Diagnostic) Diagnostic { + result := value + if value.Start != nil { + position := *value.Start + result.Start = &position + } + if value.End != nil { + position := *value.End + result.End = &position + } + return result +} diff --git a/internal/kernel/requirementsourcemodel/limits.go b/internal/kernel/requirementsourcemodel/limits.go index e514a20..f517f42 100644 --- a/internal/kernel/requirementsourcemodel/limits.go +++ b/internal/kernel/requirementsourcemodel/limits.go @@ -59,6 +59,10 @@ func DefaultLimits() Limits { } } +func ValidateLimits(value Limits) error { + return validateLimits(value) +} + func validateLimits(value Limits) error { checks := []struct { actual int diff --git a/internal/kernel/requirementsourcemodel/normalize.go b/internal/kernel/requirementsourcemodel/normalize.go index 1f34016..ca86c10 100644 --- a/internal/kernel/requirementsourcemodel/normalize.go +++ b/internal/kernel/requirementsourcemodel/normalize.go @@ -12,7 +12,7 @@ func Normalize(draft Draft) (Model, error) { } func NormalizeWithLimits(draft Draft, limits Limits) (Model, error) { - if err := validateLimits(limits); err != nil { + if err := ValidateLimits(limits); err != nil { return Model{}, err } if err := preflight(draft, limits); err != nil { diff --git a/internal/kernel/requirementsourcemodel/package_boundary_test.go b/internal/kernel/requirementsourcemodel/package_boundary_test.go index 7da7929..66c56aa 100644 --- a/internal/kernel/requirementsourcemodel/package_boundary_test.go +++ b/internal/kernel/requirementsourcemodel/package_boundary_test.go @@ -40,7 +40,7 @@ func TestRepresentationNeutralPackageBoundaryIsExact(t *testing.T) { "RiskClass", "RiskCritical", "RiskHigh", "RiskLow", "RiskMedium", "Scenario", "ScenarioValue", "SourceClarification", "SourceCodeSnapshot", "SourceDesign", "SourceKind", "SourceOwnerDecision", "SourcePlan", "TermAction", "TermKind", "TermObservable", "TermState", "TermSubject", "TermValue", - "UpdatePolicy", "ValidationError", "VocabularyTerm", + "UpdatePolicy", "ValidateLimits", "ValidationError", "VocabularyTerm", } sort.Strings(actualExports) sort.Strings(expectedExports) diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index 417120f..a0b94d8 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -696,6 +696,14 @@ "proofState": "witness_backed", "nonClaims": ["This private single-source model does not select or expose a source codec, parse or serialize a persisted source, retain a normalized mirror, establish cross-source requirement identity, authenticate derivation objects, digests, selectors, or freshness, authenticate a caller-declared sourceKind or prove its author's authority or trust class, cut over any current requirement consumer, prove requirement meaning or implementation correctness, execute native witnesses, approve merge or release, or establish rollout or production readiness."] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["This private codec does not migrate or rewrite current requirement sources, expose a public source extension or CLI command, retain a normalized mirror, authenticate requirement meaning or derivation provenance, generalize the frozen formatter screen beyond its corpus, execute native witnesses, approve merge or release, or establish rollout or production readiness."] + }, { "requirementId": "REQ-PROOFKIT-WORKFLOW-001", "ownerId": "proofkit.agent-workflow", @@ -4577,6 +4585,226 @@ "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "scenarioId": "proofkit.spec-proof-core.requirement-source-codec-selection-closure", + "witnessId": "proofkit.requirement-source-codec.selection-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcecodec/selection_test.go", + "witnessSelectors": [ + { + "selector": "TestSelectionRecordIsClosedAndDecisionIsReproducible", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestSelectionRecordIsClosedAndDecisionIsReproducible$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "scenarioId": "proofkit.spec-proof-core.requirement-source-codec-replacement-predicate-closure", + "witnessId": "proofkit.requirement-source-codec.replacement-predicate-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcecodec/selection_evaluator_test.go", + "witnessSelectors": [ + { + "selector": "TestChallengerEligibilityRequiresEveryReplacementPredicate", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestChallengerEligibilityRequiresEveryReplacementPredicate$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "scenarioId": "proofkit.spec-proof-core.requirement-source-codec-package-boundary-closure", + "witnessId": "proofkit.requirement-source-codec.package-boundary-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcecodec/package_boundary_test.go", + "witnessSelectors": [ + { + "selector": "TestProductionPackageHasOneCodecAndNoSyntaxDependency", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestProductionPackageHasOneCodecAndNoSyntaxDependency$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "scenarioId": "proofkit.spec-proof-core.requirement-source-codec-field-closure", + "witnessId": "proofkit.requirement-source-codec.field-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcecodec/field_manifest_test.go", + "witnessSelectors": [ + { + "selector": "TestFieldManifestMatchesWireDTOAndClosedShape", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestFieldManifestMatchesWireDTOAndClosedShape$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "scenarioId": "proofkit.spec-proof-core.requirement-source-codec-roundtrip-closure", + "witnessId": "proofkit.requirement-source-codec.roundtrip-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcecodec/roundtrip_test.go", + "witnessSelectors": [ + { + "selector": "TestFormatParseRoundTripPreservesEveryProjection", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestFormatParseRoundTripPreservesEveryProjection$'" + }, + { + "selector": "TestCanonicalFormatIsIdempotent", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestCanonicalFormatIsIdempotent$'" + }, + { + "selector": "TestFormatPreservesMetadataAbsenceNullAndRecord", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestFormatPreservesMetadataAbsenceNullAndRecord$'" + }, + { + "selector": "TestSourceMapReplaysKeyAndValueSpans", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestSourceMapReplaysKeyAndValueSpans$'" + }, + { + "selector": "TestReturnedSourceMapIsImmutable", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestReturnedSourceMapIsImmutable$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "scenarioId": "proofkit.spec-proof-core.requirement-source-codec-layout-closure", + "witnessId": "proofkit.requirement-source-codec.layout-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcecodec/format_layout_test.go", + "witnessSelectors": [ + { + "selector": "TestHybridLayoutKeepsStableSiblingEntityLinesUnchanged", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestHybridLayoutKeepsStableSiblingEntityLinesUnchanged$'" + }, + { + "selector": "TestCanonicalStringsEscapeUnsafeScalarsWithoutHTMLEscaping", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestCanonicalStringsEscapeUnsafeScalarsWithoutHTMLEscaping$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "scenarioId": "proofkit.spec-proof-core.requirement-source-codec-mutant-closure", + "witnessId": "proofkit.requirement-source-codec.mutant-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcecodec/mutant_manifest_test.go", + "witnessSelectors": [ + { + "selector": "TestCodecMutantManifestClosesRepresentationFailures", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestCodecMutantManifestClosesRepresentationFailures$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "scenarioId": "proofkit.spec-proof-core.requirement-source-codec-resource-closure", + "witnessId": "proofkit.requirement-source-codec.resource-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcecodec/limits_test.go", + "witnessSelectors": [ + { + "selector": "TestRawByteBoundaryIsExactAndDominatesUTF8", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestRawByteBoundaryIsExactAndDominatesUTF8$'" + }, + { + "selector": "TestDynamicMapCollectionLimitPrecedesParameterSemantics", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestDynamicMapCollectionLimitPrecedesParameterSemantics$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "scenarioId": "proofkit.spec-proof-core.requirement-source-codec-limit-formula-closure", + "witnessId": "proofkit.requirement-source-codec.limit-formula-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcecodec/limit_manifest_test.go", + "witnessSelectors": [ + { + "selector": "TestLimitCoefficientManifestMatchesProductionFormula", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestLimitCoefficientManifestMatchesProductionFormula$'" + }, + { + "selector": "TestLimitArithmeticRejectsOverflow", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestLimitArithmeticRejectsOverflow$'" + }, + { + "selector": "TestCanonicalByteBoundCoversWorstAdmittedEscapeExpansion", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestCanonicalByteBoundCoversWorstAdmittedEscapeExpansion$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "scenarioId": "proofkit.spec-proof-core.requirement-source-codec-diagnostic-closure", + "witnessId": "proofkit.requirement-source-codec.diagnostic-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcecodec/diagnostics_test.go", + "witnessSelectors": [ + { + "selector": "TestInvalidUTF8UsesByteOnlyCoordinates", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestInvalidUTF8UsesByteOnlyCoordinates$'" + }, + { + "selector": "TestValidUnicodeDiagnosticsUseScalarColumns", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestValidUnicodeDiagnosticsUseScalarColumns$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "scenarioId": "proofkit.spec-proof-core.requirement-source-codec-admission-closure", + "witnessId": "proofkit.requirement-source-codec.admission-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcecodec/admission_test.go", + "witnessSelectors": [ + { + "selector": "TestParseDiagnosticsDoNotDiscloseCallerTextOrDynamicKeys", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestParseDiagnosticsDoNotDiscloseCallerTextOrDynamicKeys$'" + }, + { + "selector": "TestParseAcceptsValidUnicodeSurrogatePairLosslessly", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestParseAcceptsValidUnicodeSurrogatePairLosslessly$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "scenarioId": "proofkit.spec-proof-core.requirement-source-codec-fuzz-seed-closure", + "witnessId": "proofkit.requirement-source-codec.fuzz-seed-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcecodec/codec_fuzz_test.go", + "witnessSelectors": [ + { + "selector": "TestFuzzSeedCorpusRoundTrips", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestFuzzSeedCorpusRoundTrips$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, { "requirementId": "REQ-PROOFKIT-SPEC-023", "scenarioId": "proofkit.spec-proof-core.requirement-traceability-topology-closure", From 8a5084165538ece550edbfaf5e20af3d9530055b Mon Sep 17 00:00:00 2001 From: iperev Date: Tue, 1 Sep 2026 21:00:26 +0200 Subject: [PATCH 2/5] fix: close requirement source codec proof boundaries --- .gitignore | 1 + .../proofkit-spec-proof-core/overview.md | 12 +- .../requirements.v1.json | 4 +- .../app/agent_workflow_version_edge_test.go | 36 +- .../requirementsourcecodec/admission_test.go | 60 +++ .../requirementsourcecodec/diagnostic_path.go | 205 +++++++++ .../diagnostics_test.go | 33 ++ .../field_manifest_test.go | 68 ++- .../requirementsourcecodec/json_index.go | 119 +++-- .../requirementsourcecodec/limits_test.go | 13 + .../package_boundary_test.go | 46 +- .../kernel/requirementsourcecodec/parse.go | 84 +--- .../requirementsourcecodec/roundtrip_test.go | 23 + .../selection_evaluator_test.go | 43 +- .../selection_evidence_test.go | 429 ++++++++++++++++++ .../requirementsourcecodec/selection_test.go | 189 +++++--- .../kernel/requirementsourcecodec/shape.go | 68 ++- .../testdata/codec-field-manifest.v1.json | 50 +- .../testdata/codec-selection.v1.json | 90 +++- .../testdata/screen-v3.tgz | Bin 0 -> 99210 bytes .../kernel/requirementsourcecodec/types.go | 2 + internal/tools/releasechange/record_test.go | 33 +- package-lock.json | 4 +- package.json | 2 +- proofkit/requirement-bindings.json | 81 +++- release/change-record.v2.json | 43 +- 26 files changed, 1394 insertions(+), 344 deletions(-) create mode 100644 internal/kernel/requirementsourcecodec/diagnostic_path.go create mode 100644 internal/kernel/requirementsourcecodec/selection_evidence_test.go create mode 100644 internal/kernel/requirementsourcecodec/testdata/screen-v3.tgz diff --git a/.gitignore b/.gitignore index 2e55bea..cc5b625 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ playwright-report/ test-results/ *.tgz +!internal/kernel/requirementsourcecodec/testdata/screen-v3.tgz .DS_Store .env diff --git a/docs/specs/proofkit-spec-proof-core/overview.md b/docs/specs/proofkit-spec-proof-core/overview.md index 14f3edd..e7963ba 100644 --- a/docs/specs/proofkit-spec-proof-core/overview.md +++ b/docs/specs/proofkit-spec-proof-core/overview.md @@ -157,10 +157,14 @@ execution receipts, and merge policy. - `REQ-PROOFKIT-SPEC-025`: a versioned disjoint-role experiment selects one private v2 source grammar, grouped JSON with an entity-local hybrid layout; its strict bounded codec delegates meaning to the representation-neutral - model, preserves every projection and metadata-presence state, emits - deterministic nondisclosing source locations, and is closed by independent - field, limit, selection, mutant, round-trip, fuzz-seed, and package-boundary - witnesses without migrating current sources or exposing a new public CLI. + model, binds each collection to one model-limit owner, preserves every + projection, metadata-presence state, and lexical source location, and emits + deterministic nondisclosing diagnostics. A bounded traversal-, symlink-, and + duplicate-closed archive byte-binds the exact V3 screen, while independent + field, limit, selection, mutant, round-trip, fuzz-seed, package, and + repository identity witnesses close the selected owner boundary without + migrating current sources, exposing a new public CLI, or claiming that no + future owner-approved grammar can be added. ## Non-Claims diff --git a/docs/specs/proofkit-spec-proof-core/requirements.v1.json b/docs/specs/proofkit-spec-proof-core/requirements.v1.json index 155f830..5555886 100644 --- a/docs/specs/proofkit-spec-proof-core/requirements.v1.json +++ b/docs/specs/proofkit-spec-proof-core/requirements.v1.json @@ -572,12 +572,12 @@ { "requirementId": "REQ-PROOFKIT-SPEC-025", "ownerId": "proofkit.spec-proof-core", - "invariant": "A private requirement-source codec selection admits exactly one v2 persisted grammar, grouped JSON with the frozen entity-local hybrid layout, after a versioned disjoint-role screen rejects compact and pretty JSON layouts for edit-locality failure and admits no YAML, TOML, or restricted-text parser authority. The selected codec maps bounded UTF-8 bytes through duplicate-, case-, unknown-, missing-, null-, integer-, and Unicode-scalar-closed structural admission into exactly one requirementsourcemodel.NormalizeWithLimits call; emits immutable atomic, authoring-layout, and typed-reference projections plus a deterministic source map; formats only a model re-admitted by the semantic owner; preserves metadata absence, present-null deferral, present-record deferral, ordered actions, and sorted dynamic maps; emits fixed-order entity-local canonical JSON with exact unsafe-scalar escaping and one final line feed; and applies raw-byte, UTF-8, lexical-token, nesting, representation-cardinality, model-resource, model-semantic, and canonical-output failure precedence without disclosing caller text. Independently authored field, limit-coefficient, selection, and executable mutant manifests close the DTO, structural schema, resource formula, candidate roles, decision, diagnostic paths, and losing-grammar boundary; round-trip, idempotence, source-span replay, exact-limit, fuzz-seed, and package-boundary witnesses reject a second production grammar or semantic owner.", + "invariant": "A private requirement-source codec selection admits grouped JSON with the frozen entity-local hybrid layout as the sole v2 persisted grammar owned by internal/kernel/requirementsourcecodec after a versioned disjoint-role screen rejects compact and pretty JSON layouts for edit-locality failure and admits no YAML, TOML, or restricted-text parser authority. The selection record byte-binds a bounded traversal-, symlink-, and duplicate-closed binary archive and the exact extracted V3 evidence tree, including its method sources, fixture corpus, rendered candidates, independent token reports, review results, validation, and decision, and its metric registry, layout order, thresholds, observations, and evaluator have exact decision closure. The selected codec maps bounded UTF-8 bytes through duplicate-, case-, unknown-, missing-, null-, integer-, and Unicode-scalar-closed structural admission into exactly one requirementsourcemodel.NormalizeWithLimits call; each wire collection names one model-limit owner; immutable atomic, authoring-layout, typed-reference, and lexical source-map projections preserve metadata absence, present-null deferral, present-record deferral, ordered actions, sorted dynamic maps, and caller wire order; and formatting re-admits the semantic model before emitting fixed-order entity-local canonical JSON with exact unsafe-scalar escaping and one final line feed. Raw-byte, UTF-8, lexical-token, nesting, representation-cardinality, model-resource, model-semantic, and canonical-output failures have fixed precedence, while diagnostic paths contain only canonical field names, numeric indexes, or placeholders and resolve synthetic model identities to exact wire spans. Independently authored field, limit-coefficient, selection, and executable mutant manifests close DTO fields and cardinalities, structural schema, resource formula, candidate roles, decision, diagnostic paths, and losing-grammar dependencies; round-trip, idempotence, source-span replay, exact-limit, fuzz-seed, exact package inventory, and repository-wide unique proofkit.requirement-source identity witnesses close the currently selected owner boundary without claiming an open-world ban on future grammars.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": ["proofkit/requirement-bindings.json"], "nonClaimRefs": ["NC-PROOFKIT-SPEC-025"], - "nonClaims": ["This private codec does not migrate or rewrite current requirement sources, expose a public source extension or CLI command, retain a normalized mirror, authenticate requirement meaning or derivation provenance, generalize the frozen formatter screen beyond its corpus, execute native witnesses, approve merge or release, or establish rollout or production readiness."], + "nonClaims": ["This private codec does not migrate or rewrite current requirement sources, expose a public source extension or CLI command, retain a normalized mirror, authenticate requirement meaning or derivation provenance, generalize the frozen formatter screen beyond its byte-bound corpus, prevent a future owner-approved grammar from being added, execute native witnesses, approve merge or release, or establish rollout or production readiness."], "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, "deferral": null, "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} diff --git a/internal/app/agent_workflow_version_edge_test.go b/internal/app/agent_workflow_version_edge_test.go index d489646..bcd5c60 100644 --- a/internal/app/agent_workflow_version_edge_test.go +++ b/internal/app/agent_workflow_version_edge_test.go @@ -9,9 +9,7 @@ import ( "slices" "testing" - "github.com/research-engineering/agentic-proofkit/internal/command/jsonreportcliadaptersource" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" - "github.com/research-engineering/agentic-proofkit/internal/tools/releasechange" ) const agentWorkflowVersionEdgePath = "internal/app/testdata/v0.5-wire-observations.json" @@ -40,11 +38,7 @@ type agentWorkflowCommandContract struct { func TestAgentWorkflowVersionEdgeClosesPublicWireAdditions(t *testing.T) { record := readAgentWorkflowVersionEdge(t) - releaseRecord, err := releasechange.Read(filepath.Join(repoRoot(t), releasechange.RecordPath)) - if err != nil { - t.Fatal(err) - } - if err := validateAgentWorkflowVersionEdge(record, releaseRecord); err != nil { + if err := validateAgentWorkflowVersionEdge(record); err != nil { t.Fatal(err) } @@ -74,7 +68,7 @@ func TestAgentWorkflowVersionEdgeClosesPublicWireAdditions(t *testing.T) { t.Run(mutant.name, func(t *testing.T) { value := cloneAgentWorkflowVersionEdge(record) mutant.mutate(&value) - if err := validateAgentWorkflowVersionEdge(value, releaseRecord); err == nil { + if err := validateAgentWorkflowVersionEdge(value); err == nil { t.Fatal("version-edge mutant was admitted") } }) @@ -114,38 +108,32 @@ func readAgentWorkflowVersionEdge(t *testing.T) agentWorkflowVersionEdge { return decoded } -func validateAgentWorkflowVersionEdge(record agentWorkflowVersionEdge, releaseRecord releasechange.Record) error { +func validateAgentWorkflowVersionEdge(record agentWorkflowVersionEdge) error { if record.SchemaVersion != 1 || record.EdgeID != "proofkit.public-wire.0.4.0-to-0.5.0" || record.EvidenceClass != "owner_authored_frozen_version_edge_observation" { return fmt.Errorf("version-edge identity is invalid") } - if record.PreviousVersion != releaseRecord.PreviousVersion || record.Version != releaseRecord.Version { + if record.PreviousVersion != "0.4.0" || record.Version != "0.5.0" { return fmt.Errorf("version-edge release identity is stale") } - if record.PreviousPublicABISHA256 != "sha256:fc03740aea9e7f525a4388e5d7f557cde07e11b0db0c05101fe937c28a1129d9" || record.CurrentPublicABISHA256 != "sha256:"+cliContractPublicABISHA256 || record.PreviousPublicABISHA256 == record.CurrentPublicABISHA256 { + if record.PreviousPublicABISHA256 != "sha256:fc03740aea9e7f525a4388e5d7f557cde07e11b0db0c05101fe937c28a1129d9" || record.CurrentPublicABISHA256 != "sha256:9ecd2c3d2f3f360088409f7e91cce406fc1d1d6edda1b404fce119985c4fb623" || record.PreviousPublicABISHA256 == record.CurrentPublicABISHA256 { return fmt.Errorf("version-edge ABI identity is invalid") } - if record.PreviousTypeScriptGeneratorID != "proofkit.json-report-cli-adapter-source.typescript.v1" || record.CurrentTypeScriptGeneratorID != jsonreportcliadaptersource.TypeScriptGeneratorID || record.PreviousTypeScriptGeneratorID == record.CurrentTypeScriptGeneratorID { + if record.PreviousTypeScriptGeneratorID != "proofkit.json-report-cli-adapter-source.typescript.v1" || record.CurrentTypeScriptGeneratorID != "proofkit.json-report-cli-adapter-source.typescript.v2" || record.PreviousTypeScriptGeneratorID == record.CurrentTypeScriptGeneratorID { return fmt.Errorf("version-edge TypeScript generator identity is invalid") } expectedCommands := []agentWorkflowCommandContract{ - {Command: "change-workflow-plan", InputContractSHA256: generatedCommandContractMetadataByName["change-workflow-plan"].InputContractSHA256, OutputContractSHA256: generatedCommandContractMetadataByName["change-workflow-plan"].OutputContractSHA256}, - {Command: "native-evidence-guidance", InputContractSHA256: generatedCommandContractMetadataByName["native-evidence-guidance"].InputContractSHA256, OutputContractSHA256: generatedCommandContractMetadataByName["native-evidence-guidance"].OutputContractSHA256}, + {Command: "change-workflow-plan", InputContractSHA256: "sha256:e3124fc636b7f66b24daf8e1435cea11da15a741abeabe0cc3d3890b13c71625", OutputContractSHA256: "sha256:cd035e9b71d83c341b1a937a18699fd727cb4b0d694983d715b064292ae4d8bd"}, + {Command: "native-evidence-guidance", InputContractSHA256: "", OutputContractSHA256: "sha256:c1d23df574e948ea7160931f53790a6d133ae12eeceefe9e5fa15430d653ff7e"}, } if !slices.Equal(record.AddedCommandContracts, expectedCommands) { return fmt.Errorf("version-edge added command contracts are not exact") } - additions := make([]string, 0, len(releaseRecord.Additions)) - for _, change := range releaseRecord.Additions { - additions = append(additions, change.ChangeID) - } - if !slices.Equal(record.AdditionChangeIDs, additions) { + expectedAdditions := []string{"proofkit.agent-workflow.change-planner", "proofkit.agent-workflow.native-evidence-guidance", "proofkit.release.cross-carrier-binary-identity"} + if !slices.Equal(record.AdditionChangeIDs, expectedAdditions) { return fmt.Errorf("version-edge addition owners are not exact") } - breaking := make([]string, 0, len(releaseRecord.BreakingChanges)) - for _, change := range releaseRecord.BreakingChanges { - breaking = append(breaking, change.ChangeID) - } - if !slices.Equal(record.BreakingChangeIDs, breaking) { + expectedBreaking := []string{"proofkit.agent-envelope.local-identity-closure", "proofkit.diagnostic.bounded-error-boundary", "proofkit.stable-json.unicode-scalar-v2"} + if !slices.Equal(record.BreakingChangeIDs, expectedBreaking) { return fmt.Errorf("version-edge breaking change owners are not exact") } if !slices.Equal(record.NonClaims, []string{"This owner-authored version-edge observation binds reviewed public contract identities; it does not authenticate Git history, registry publication, provider ingestion, native witness truth, rollout, or production readiness."}) { diff --git a/internal/kernel/requirementsourcecodec/admission_test.go b/internal/kernel/requirementsourcecodec/admission_test.go index 00fc69f..0285b26 100644 --- a/internal/kernel/requirementsourcecodec/admission_test.go +++ b/internal/kernel/requirementsourcecodec/admission_test.go @@ -38,6 +38,66 @@ func TestParseDiagnosticsDoNotDiscloseCallerTextOrDynamicKeys(t *testing.T) { } } +func TestPreShapeDiagnosticsDoNotDiscloseUnknownKeys(t *testing.T) { + const sentinel = "ghp_0123456789abcdefghijklmnopqrstuvwxyz" + payload := []byte(`{"` + sentinel + `":` + strings.Repeat("[", defaultMaxNesting+1) + `0` + strings.Repeat("]", defaultMaxNesting+1) + `}`) + _, err := Parse(payload) + if ErrorCode(err) != "nesting_limit_exceeded" { + t.Fatalf("Parse() error = %v", err) + } + diagnostic := err.(*Error).Diagnostic() + if strings.Contains(err.Error(), sentinel) || strings.Contains(diagnostic.Path, sentinel) || !strings.HasPrefix(diagnostic.Path, "/") { + t.Fatalf("pre-shape diagnostic disclosed caller key: %#v", diagnostic) + } +} + +func TestParseDiagnosticsRedactSemanticEntityIDsAndResolveExactSpan(t *testing.T) { + const definitionID = "NCL-CODEC-UNREFERENCED" + payload := mutateRoot(t, mustPayload(t), func(root map[string]any) { + definitions := root["nonClaimDefinitions"].([]any) + root["nonClaimDefinitions"] = append(definitions, map[string]any{ + "nonClaimId": definitionID, + "statement": "This declaration is intentionally unreferenced.", + }) + }) + _, err := Parse(payload) + assertDiagnostic(t, err, "unreferenced_definition", "/nonClaimDefinitions/4") + diagnostic := err.(*Error).Diagnostic() + if strings.Contains(err.Error(), definitionID) || strings.Contains(diagnostic.Path, definitionID) || !bytes.Contains(payload[diagnostic.Span.Start:diagnostic.Span.End], []byte(`"nonClaimId":"`+definitionID+`"`)) { + t.Fatalf("definition diagnostic is not identity-safe and exact: %#v", diagnostic) + } + + requirementPayload := mutateRoot(t, mustPayload(t), func(root map[string]any) { + groups := root["groups"].([]any) + members := groups[0].(map[string]any)["members"].([]any) + fields := members[0].(map[string]any)["fields"].(map[string]any) + fields["deferral"] = nil + }) + _, err = Parse(requirementPayload) + if ErrorCode(err) != "missing_deferral" { + t.Fatalf("requirement semantic error = %v", err) + } + requirementDiagnostic := err.(*Error).Diagnostic() + if strings.Contains(requirementDiagnostic.Path, "REQ-") || requirementDiagnostic.Path != "/groups/0/members/0/fields/deferral" { + t.Fatalf("requirement diagnostic path = %q", requirementDiagnostic.Path) + } + + profilePayload := mutateRoot(t, mustPayload(t), func(root map[string]any) { + profiles := root["profiles"].([]any) + copyValue := map[string]any{} + for key, value := range profiles[0].(map[string]any) { + copyValue[key] = value + } + copyValue["profileId"] = "RPROF-CODEC-UNUSED" + root["profiles"] = append(profiles, copyValue) + }) + _, err = Parse(profilePayload) + assertDiagnostic(t, err, "vacuous_profile", "/profiles/1") + if strings.Contains(err.Error(), "RPROF-CODEC-UNUSED") { + t.Fatalf("profile diagnostic disclosed caller identity: %v", err) + } +} + func TestParseRejectsMalformedJSON(t *testing.T) { for _, payload := range [][]byte{ {}, diff --git a/internal/kernel/requirementsourcecodec/diagnostic_path.go b/internal/kernel/requirementsourcecodec/diagnostic_path.go new file mode 100644 index 0000000..5828eed --- /dev/null +++ b/internal/kernel/requirementsourcecodec/diagnostic_path.go @@ -0,0 +1,205 @@ +package requirementsourcecodec + +import ( + "strconv" + "strings" +) + +type diagnosticPath struct { + lookup string + reported string +} + +func resolveModelPath(wire document, path string) diagnosticPath { + segments := modelPathSegments(path) + if len(segments) == 0 { + return diagnosticPath{} + } + switch segments[0] { + case "requirements": + return resolveRequirementPath(wire, segments) + case "profiles": + return resolveIdentifiedPath(segments, "profiles", len(wire.Profiles), func(index int) string { return wire.Profiles[index].ProfileID }) + case "nonClaimDefinitions": + return resolveIdentifiedPath(segments, "nonClaimDefinitions", len(wire.NonClaimDefinitions), func(index int) string { return wire.NonClaimDefinitions[index].NonClaimID }) + case "vocabulary": + return resolveIdentifiedPath(segments, "vocabulary", len(wire.Vocabulary), func(index int) string { return wire.Vocabulary[index].TermID }) + default: + return conventionalModelPath(segments) + } +} + +func resolveRequirementPath(wire document, segments []string) diagnosticPath { + if len(segments) < 2 { + return conventionalModelPath(segments) + } + for groupIndex, groupValue := range wire.Groups { + for memberIndex, memberValue := range groupValue.Members { + if memberValue.RequirementID != segments[1] { + continue + } + base := pointer("groups", groupIndex, "members", memberIndex) + tail := segments[2:] + if len(tail) == 0 { + return sameDiagnosticPath(base) + } + if isMetadataField(tail[0]) { + base = metadataOwnerPath(wire, groupValue, memberValue, groupIndex, memberIndex, tail[0]) + base = joinPointer(base, tail[0]) + tail = tail[1:] + } + return appendSafeSegments(base, tail) + } + } + return diagnosticPath{lookup: "/groups", reported: "/groups/"} +} + +func metadataOwnerPath(wire document, groupValue group, memberValue member, groupIndex int, memberIndex int, field string) string { + memberBase := pointer("groups", groupIndex, "members", memberIndex, "fields") + if metadataFieldPresent(memberValue.Fields, field) || groupValue.ProfileID == "" { + return memberBase + } + for profileIndex, profileValue := range wire.Profiles { + if profileValue.ProfileID == groupValue.ProfileID && metadataFieldPresent(profileValue.Fields, field) { + return pointer("profiles", profileIndex, "fields") + } + } + return memberBase +} + +func metadataFieldPresent(fields metadataFields, field string) bool { + switch field { + case "ownerId": + return fields.OwnerID != nil + case "claimLevel": + return fields.ClaimLevel != nil + case "riskClass": + return fields.RiskClass != nil + case "nonClaimRefs": + return fields.NonClaimRefs != nil + case "lifecycle": + return fields.Lifecycle != nil + case "deferral": + return fields.Deferral != nil + case "updatePolicy": + return fields.UpdatePolicy != nil + default: + return false + } +} + +func isMetadataField(value string) bool { + switch value { + case "ownerId", "claimLevel", "riskClass", "nonClaimRefs", "lifecycle", "deferral", "updatePolicy": + return true + default: + return false + } +} + +func resolveIdentifiedPath(segments []string, root string, count int, identity func(int) string) diagnosticPath { + if len(segments) < 2 { + return conventionalModelPath(segments) + } + if index, err := strconv.Atoi(segments[1]); err == nil && index >= 0 && index < count { + return appendSafeSegments(pointer(root, index), segments[2:]) + } + for index := 0; index < count; index++ { + if identity(index) == segments[1] { + return appendSafeSegments(pointer(root, index), segments[2:]) + } + } + return diagnosticPath{lookup: "/" + root, reported: "/" + root + "/"} +} + +func conventionalModelPath(segments []string) diagnosticPath { + lookup := "" + reported := "" + dynamicEntry := false + for _, segment := range segments { + lookup = joinPointer(lookup, segment) + reportedSegment := segment + if dynamicEntry { + reportedSegment = "" + dynamicEntry = false + } + reported = joinPointer(reported, reportedSegment) + if segment == "values" { + dynamicEntry = true + } + } + return diagnosticPath{lookup: lookup, reported: reported} +} + +func appendSafeSegments(base string, segments []string) diagnosticPath { + lookup := base + reported := base + for _, segment := range segments { + lookup = joinPointer(lookup, segment) + reported = joinPointer(reported, segment) + } + return diagnosticPath{lookup: lookup, reported: reported} +} + +func sameDiagnosticPath(path string) diagnosticPath { + return diagnosticPath{lookup: path, reported: path} +} + +func pointer(values ...any) string { + result := "" + for _, value := range values { + switch typed := value.(type) { + case string: + result = joinPointer(result, typed) + case int: + result = joinPointer(result, strconv.Itoa(typed)) + default: + panic("unsupported pointer component") + } + } + return result +} + +func closestLocation(locations map[string]rawLocation, path string) rawLocation { + for current := path; ; { + if location, exists := locations[current]; exists { + return location + } + index := strings.LastIndex(current, "/") + if index < 0 { + break + } + current = current[:index] + } + return locations[""] +} + +func modelPathSegments(path string) []string { + segments := make([]string, 0, 8) + for offset := 0; offset < len(path); { + switch path[offset] { + case '.': + offset++ + case '[': + end := strings.IndexByte(path[offset:], ']') + if end < 0 { + return segments + } + end += offset + if _, err := strconv.Atoi(path[offset+1 : end]); err == nil { + segments = append(segments, path[offset+1:end]) + } + offset = end + 1 + default: + end := offset + for end < len(path) && path[end] != '.' && path[end] != '[' { + end++ + } + if end > offset { + segments = append(segments, path[offset:end]) + } + offset = end + } + } + return segments +} diff --git a/internal/kernel/requirementsourcecodec/diagnostics_test.go b/internal/kernel/requirementsourcecodec/diagnostics_test.go index 9901c12..b113fa4 100644 --- a/internal/kernel/requirementsourcecodec/diagnostics_test.go +++ b/internal/kernel/requirementsourcecodec/diagnostics_test.go @@ -2,6 +2,7 @@ package requirementsourcecodec import ( "bytes" + "strings" "testing" ) @@ -18,6 +19,38 @@ func TestInvalidUTF8UsesByteOnlyCoordinates(t *testing.T) { } } +func TestBareCRAndCRLFAdvanceScalarLinesOnce(t *testing.T) { + for _, item := range []struct { + name string + separator string + }{ + {name: "bare CR", separator: "\r"}, + {name: "CRLF", separator: "\r\n"}, + } { + t.Run(item.name, func(t *testing.T) { + payload := []byte(strings.Join([]string{ + "{", ` "kind":"proofkit.requirement-source",`, ` "schemaVersion":2,`, ` "sourceId":"safe",`, ` "extra":true`, "}", + }, item.separator)) + _, err := Parse(payload) + assertDiagnostic(t, err, "unknown_field", "/") + start := err.(*Error).Diagnostic().Start + if start == nil || start.Line != 5 || start.ScalarColumn != 3 { + t.Fatalf("diagnostic start = %#v", start) + } + }) + } +} + +func TestMultipleValueDiagnosticSpansSecondToken(t *testing.T) { + payload := append(append([]byte(nil), mustPayload(t)...), []byte(" true")...) + _, err := Parse(payload) + assertDiagnostic(t, err, "multiple_values", "") + span := err.(*Error).Diagnostic().Span + if !bytes.Equal(payload[span.Start:span.End], []byte("true")) { + t.Fatalf("multiple-value span = %q", payload[span.Start:span.End]) + } +} + func TestValidUnicodeDiagnosticsUseScalarColumns(t *testing.T) { payload := []byte("{\n \"kind\": \"proofkit.requirement-source\",\n \"schemaVersion\": 2,\n \"sourceId\": \"\u03bb\",\n \"extra\": true\n}") _, err := Parse(payload) diff --git a/internal/kernel/requirementsourcecodec/field_manifest_test.go b/internal/kernel/requirementsourcecodec/field_manifest_test.go index 0ad3e32..d16475d 100644 --- a/internal/kernel/requirementsourcecodec/field_manifest_test.go +++ b/internal/kernel/requirementsourcecodec/field_manifest_test.go @@ -26,11 +26,12 @@ type manifestRecord struct { } type manifestField struct { - Name string `json:"name"` - Type string `json:"type"` - Required bool `json:"required"` - Nullable bool `json:"nullable"` - Constant string `json:"constant,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Required bool `json:"required"` + Nullable bool `json:"nullable"` + Constant string `json:"constant,omitempty"` + LimitOwner string `json:"limitOwner,omitempty"` } func TestFieldManifestMatchesWireDTOAndClosedShape(t *testing.T) { @@ -91,7 +92,7 @@ func wireRecordManifest(t *testing.T) []manifestRecord { t.Fatalf("%s.%s has invalid JSON tag", recordType.Name(), field.Name) } fieldType, nullable := manifestType(field.Type, recordType.Name(), name) - item := manifestField{Name: name, Type: fieldType, Required: !options["omitempty"], Nullable: nullable} + item := manifestField{Name: name, Type: fieldType, Required: !options["omitempty"], Nullable: nullable, LimitOwner: manifestLimitOwner(recordType.Name(), name)} if recordType == reflect.TypeOf(document{}) && name == "schemaVersion" { item.Constant = "2" } @@ -151,7 +152,7 @@ func assertShapeRecord(t *testing.T, recordID string, actual *shape, records map if !exists || shapeField.required != field.Required || shapeField.shape.nullable != field.Nullable { t.Fatalf("shape field %s.%s mismatch", recordID, field.Name) } - assertShapeType(t, field.Type, shapeField.shape, records, seen) + assertShapeType(t, field.Type, field.LimitOwner, shapeField.shape, records, seen) if field.Constant != "" { switch shapeField.shape.kind { case shapeString: @@ -169,24 +170,65 @@ func assertShapeRecord(t *testing.T, recordID string, actual *shape, records map } } -func assertShapeType(t *testing.T, expected string, actual *shape, records map[string]manifestRecord, seen map[string]struct{}) { +func assertShapeType(t *testing.T, expected string, limitOwner string, actual *shape, records map[string]manifestRecord, seen map[string]struct{}) { t.Helper() switch { - case expected == "string" && actual.kind == shapeString: + case expected == "string" && actual.kind == shapeString && limitOwner == "" && actual.limitOwner == "": return - case expected == "boolean" && actual.kind == shapeBoolean: + case expected == "boolean" && actual.kind == shapeBoolean && limitOwner == "" && actual.limitOwner == "": return - case expected == "integer" && actual.kind == shapeInteger: + case expected == "integer" && actual.kind == shapeInteger && limitOwner == "" && actual.limitOwner == "": return case expected == "map:string" && actual.kind == shapeObject && actual.dynamic != nil && actual.dynamic.kind == shapeString: + assertShapeLimitOwner(t, limitOwner, actual) return case strings.HasPrefix(expected, "array:") && actual.kind == shapeArray: - assertShapeType(t, strings.TrimPrefix(expected, "array:"), actual.element, records, seen) + assertShapeLimitOwner(t, limitOwner, actual) + assertShapeType(t, strings.TrimPrefix(expected, "array:"), "", actual.element, records, seen) return - case strings.HasPrefix(expected, "record:") && actual.kind == shapeObject: + case strings.HasPrefix(expected, "record:") && actual.kind == shapeObject && limitOwner == "" && actual.limitOwner == "": assertShapeRecord(t, strings.TrimPrefix(expected, "record:"), actual, records, seen) return default: t.Fatalf("shape type mismatch: expected %s, actual kind %d", expected, actual.kind) } } + +func assertShapeLimitOwner(t *testing.T, expected string, actual *shape) { + t.Helper() + owner := collectionLimitID(expected) + if expected == "" || actual.limitOwner != owner || actual.maxItems != collectionLimit(owner, requirementsourcemodel.DefaultLimits()) { + t.Fatalf("shape limit owner = %q/%d, want %q", actual.limitOwner, actual.maxItems, expected) + } +} + +func manifestLimitOwner(recordID string, fieldName string) string { + owners := map[string]collectionLimitID{ + "deferral.evidenceRefs": limitCollectionItems, + "derivation.requirementIds": limitCollectionItems, + "derivation.nonClaimRefs": limitCollectionItems, + "document.sourceNonClaimRefs": limitCollectionItems, + "document.nonClaimDefinitions": limitDefinitions, + "document.vocabulary": limitTerms, + "document.derivations": limitDerivations, + "document.profiles": limitProfiles, + "document.groups": limitGroups, + "document.scenarios": limitScenarios, + "example.values": limitCollectionItems, + "group.sharedPremises": limitCollectionItems, + "group.members": limitMembersPerGroup, + "lifecycle.replacementRequirementIds": limitCollectionItems, + "lifecycle.evidenceRefs": limitCollectionItems, + "metadataFields.nonClaimRefs": limitCollectionItems, + "scenario.requirementIds": limitCollectionItems, + "scenario.parameters": limitCollectionItems, + "scenario.preconditions": limitCollectionItems, + "scenario.actionSequence": limitCollectionItems, + "scenario.expectedObservations": limitCollectionItems, + "scenario.forbiddenObservations": limitCollectionItems, + "scenario.examples": limitExamplesPerScenario, + "scenario.vocabularyRefs": limitCollectionItems, + "scenario.nonClaimRefs": limitCollectionItems, + } + return string(owners[recordID+"."+fieldName]) +} diff --git a/internal/kernel/requirementsourcecodec/json_index.go b/internal/kernel/requirementsourcecodec/json_index.go index af9c2be..7c34dc0 100644 --- a/internal/kernel/requirementsourcecodec/json_index.go +++ b/internal/kernel/requirementsourcecodec/json_index.go @@ -25,22 +25,22 @@ type jsonIndexer struct { source []byte decoder *json.Decoder limits Limits - tokens int locations map[string]rawLocation } -func indexJSON(source []byte, limits Limits) (indexedValue, error) { +func indexJSON(source []byte, limits Limits, expected *shape) (indexedValue, error) { decoder := json.NewDecoder(bytes.NewReader(source)) decoder.UseNumber() indexer := &jsonIndexer{source: source, decoder: decoder, limits: limits, locations: map[string]rawLocation{}} - value, _, err := indexer.parseValue("", 1) + value, _, err := indexer.parseValue("", "", 1, expected) if err != nil { return indexedValue{}, err } before := decoder.InputOffset() _, err = decoder.Token() if err == nil { - return indexedValue{}, diagnosticError(source, "multiple_values", "", ByteSpan{Start: before, End: before}, true) + after := decoder.InputOffset() + return indexedValue{}, diagnosticError(source, "multiple_values", "", tokenSpan(source, before, after), true) } if !errors.Is(err, io.EOF) { return indexedValue{}, syntaxError(source, decoder.InputOffset()) @@ -48,91 +48,99 @@ func indexJSON(source []byte, limits Limits) (indexedValue, error) { return indexedValue{value: value, locations: indexer.locations}, nil } -func (indexer *jsonIndexer) parseValue(path string, depth int) (any, ByteSpan, error) { +func (indexer *jsonIndexer) parseValue(rawPath string, safePath string, depth int, expected *shape) (any, ByteSpan, error) { if depth > indexer.limits.MaxNesting { offset := indexer.decoder.InputOffset() - return nil, ByteSpan{}, diagnosticError(indexer.source, "nesting_limit_exceeded", path, ByteSpan{Start: offset, End: offset}, true) + return nil, ByteSpan{}, diagnosticError(indexer.source, "nesting_limit_exceeded", safePath, ByteSpan{Start: offset, End: offset}, true) } - token, span, err := indexer.nextToken(path) + token, span, err := indexer.nextToken(safePath) if err != nil { return nil, ByteSpan{}, err } delimiter, isDelimiter := token.(json.Delim) if !isDelimiter { - indexer.locations[path] = rawLocation{value: span} + indexer.locations[rawPath] = rawLocation{value: span} return token, span, nil } switch delimiter { case '{': - return indexer.parseObject(path, depth, span) + return indexer.parseObject(rawPath, safePath, depth, span, expected) case '[': - return indexer.parseArray(path, depth, span) + return indexer.parseArray(rawPath, safePath, depth, span, expected) default: - return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_syntax", path, span, true) + return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_syntax", safePath, span, true) } } -func (indexer *jsonIndexer) parseObject(path string, depth int, opening ByteSpan) (any, ByteSpan, error) { +func (indexer *jsonIndexer) parseObject(rawPath string, safePath string, depth int, opening ByteSpan, expected *shape) (any, ByteSpan, error) { result := map[string]any{} seen := map[string]struct{}{} for indexer.decoder.More() { - keyToken, keySpan, err := indexer.nextToken(path) + keyToken, keySpan, err := indexer.nextToken(safePath) if err != nil { return nil, ByteSpan{}, err } key, ok := keyToken.(string) if !ok { - return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_object_key", path, keySpan, true) + return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_object_key", safePath, keySpan, true) } if _, exists := seen[key]; exists { - return nil, ByteSpan{}, diagnosticError(indexer.source, "duplicate_field", path, keySpan, true) + return nil, ByteSpan{}, diagnosticError(indexer.source, "duplicate_field", safePath, keySpan, true) } seen[key] = struct{}{} - childPath := joinPointer(path, key) - value, _, err := indexer.parseValue(childPath, depth+1) + rawChildPath := joinPointer(rawPath, key) + safeKey, childShape := safeObjectChild(expected, key) + safeChildPath := joinPointer(safePath, safeKey) + value, _, err := indexer.parseValue(rawChildPath, safeChildPath, depth+1, childShape) if err != nil { return nil, ByteSpan{}, err } - location := indexer.locations[childPath] + location := indexer.locations[rawChildPath] location.key = &keySpan - indexer.locations[childPath] = location + indexer.locations[rawChildPath] = location result[key] = value } - closingToken, closingSpan, err := indexer.nextToken(path) + closingToken, closingSpan, err := indexer.nextToken(safePath) if err != nil { return nil, ByteSpan{}, err } if closingToken != json.Delim('}') { - return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_syntax", path, closingSpan, true) + return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_syntax", safePath, closingSpan, true) } span := ByteSpan{Start: opening.Start, End: closingSpan.End} - location := indexer.locations[path] + location := indexer.locations[rawPath] location.value = span - indexer.locations[path] = location + indexer.locations[rawPath] = location return result, span, nil } -func (indexer *jsonIndexer) parseArray(path string, depth int, opening ByteSpan) (any, ByteSpan, error) { +func (indexer *jsonIndexer) parseArray(rawPath string, safePath string, depth int, opening ByteSpan, expected *shape) (any, ByteSpan, error) { result := []any{} + var childShape *shape + if expected != nil && expected.kind == shapeArray { + childShape = expected.element + } for index := 0; indexer.decoder.More(); index++ { - childPath := joinPointer(path, strconv.Itoa(index)) - value, _, err := indexer.parseValue(childPath, depth+1) + indexValue := strconv.Itoa(index) + rawChildPath := joinPointer(rawPath, indexValue) + safeChildPath := joinPointer(safePath, indexValue) + value, _, err := indexer.parseValue(rawChildPath, safeChildPath, depth+1, childShape) if err != nil { return nil, ByteSpan{}, err } result = append(result, value) } - closingToken, closingSpan, err := indexer.nextToken(path) + closingToken, closingSpan, err := indexer.nextToken(safePath) if err != nil { return nil, ByteSpan{}, err } if closingToken != json.Delim(']') { - return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_syntax", path, closingSpan, true) + return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_syntax", safePath, closingSpan, true) } span := ByteSpan{Start: opening.Start, End: closingSpan.End} - location := indexer.locations[path] + location := indexer.locations[rawPath] location.value = span - indexer.locations[path] = location + indexer.locations[rawPath] = location return result, span, nil } @@ -143,17 +151,49 @@ func (indexer *jsonIndexer) nextToken(path string) (any, ByteSpan, error) { return nil, ByteSpan{}, syntaxError(indexer.source, indexer.decoder.InputOffset()) } after := indexer.decoder.InputOffset() - indexer.tokens++ span := tokenSpan(indexer.source, before, after) - if indexer.tokens > indexer.limits.MaxTokens { - return nil, ByteSpan{}, diagnosticError(indexer.source, "token_limit_exceeded", path, span, true) - } if _, ok := token.(string); ok && !validJSONStringToken(indexer.source[span.Start:span.End]) { return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_unicode_escape", path, span, true) } return token, span, nil } +func preflightTokenLimit(source []byte, limit int) error { + decoder := json.NewDecoder(bytes.NewReader(source)) + decoder.UseNumber() + for count := 1; ; count++ { + before := decoder.InputOffset() + _, err := decoder.Token() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return syntaxError(source, decoder.InputOffset()) + } + if count > limit { + return diagnosticError(source, "token_limit_exceeded", "", tokenSpan(source, before, decoder.InputOffset()), true) + } + } +} + +func safeObjectChild(expected *shape, key string) (string, *shape) { + if expected == nil || expected.kind != shapeObject { + return "", nil + } + if expected.dynamic != nil { + return "", expected.dynamic + } + if field, exists := expected.fields[key]; exists { + return key, field.shape + } + for canonical, field := range expected.fields { + if strings.EqualFold(key, canonical) { + return canonical, field.shape + } + } + return "", nil +} + func validJSONStringToken(token []byte) bool { if len(token) < 2 || token[0] != '"' || token[len(token)-1] != '"' { return false @@ -254,6 +294,7 @@ func positionsAt(source []byte, offsets []int64) map[int64]Position { column := 1 byteOffset := 0 index := 0 + previousCR := false for index < len(unique) && unique[index] == 0 { result[unique[index]] = Position{Line: line, ScalarColumn: column} index++ @@ -261,11 +302,19 @@ func positionsAt(source []byte, offsets []int64) map[int64]Position { for byteOffset < len(source) { value, width := utf8.DecodeRune(source[byteOffset:]) byteOffset += width - if value == '\n' { + if value == '\r' { line++ column = 1 + previousCR = true + } else if value == '\n' { + if !previousCR { + line++ + } + column = 1 + previousCR = false } else { column++ + previousCR = false } for index < len(unique) && int64(byteOffset) >= unique[index] { result[unique[index]] = Position{Line: line, ScalarColumn: column} diff --git a/internal/kernel/requirementsourcecodec/limits_test.go b/internal/kernel/requirementsourcecodec/limits_test.go index f634599..cdd7596 100644 --- a/internal/kernel/requirementsourcecodec/limits_test.go +++ b/internal/kernel/requirementsourcecodec/limits_test.go @@ -56,6 +56,19 @@ func TestTokenAndNestingLimitsPrecedeShapeAdmission(t *testing.T) { } } +func TestLexicalTokenLimitDominatesNestingWhenBothFail(t *testing.T) { + modelLimits := compactTestModelLimits() + codecLimits := pairedCodecLimits(t, modelLimits) + payload := []byte(strings.Repeat("[", codecLimits.MaxNesting+1) + strings.Repeat("0,", codecLimits.MaxTokens) + "0" + strings.Repeat("]", codecLimits.MaxNesting+1)) + if int64(len(payload)) > codecLimits.MaxRawBytes { + t.Fatal("combined precedence falsifier unexpectedly exceeds the raw-byte bound") + } + _, err := ParseWithLimits(payload, codecLimits, modelLimits) + if ErrorCode(err) != "token_limit_exceeded" { + t.Fatalf("combined token/nesting error = %v", err) + } +} + func TestRepresentationCollectionLimitPrecedesModelSemantics(t *testing.T) { modelLimits := compactTestModelLimits() codecLimits := pairedCodecLimits(t, modelLimits) diff --git a/internal/kernel/requirementsourcecodec/package_boundary_test.go b/internal/kernel/requirementsourcecodec/package_boundary_test.go index ed0907f..58d0df8 100644 --- a/internal/kernel/requirementsourcecodec/package_boundary_test.go +++ b/internal/kernel/requirementsourcecodec/package_boundary_test.go @@ -4,6 +4,7 @@ import ( "go/ast" "go/parser" "go/token" + "os" "path/filepath" "reflect" "sort" @@ -71,7 +72,7 @@ func TestProductionPackageHasOneCodecAndNoSyntaxDependency(t *testing.T) { } } } - wantFiles := []string{"document.go", "format.go", "json_index.go", "limits.go", "parse.go", "shape.go", "types.go"} + wantFiles := []string{"diagnostic_path.go", "document.go", "format.go", "json_index.go", "limits.go", "parse.go", "shape.go", "types.go"} if !reflect.DeepEqual(productionFiles, wantFiles) { t.Fatalf("production codec files = %v, want exact single-grammar surface %v", productionFiles, wantFiles) } @@ -87,3 +88,46 @@ func TestProductionPackageHasOneCodecAndNoSyntaxDependency(t *testing.T) { t.Fatalf("exported codec surface = %v, want %v", exported, want) } } + +func TestRepositoryHasOneV2GrammarIdentityOwner(t *testing.T) { + repositoryRoot := filepath.Clean(filepath.Join("..", "..", "..")) + owners := []string{} + err := filepath.WalkDir(repositoryRoot, func(pathValue string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + switch entry.Name() { + case ".git", "artifacts", "dist", "node_modules", "testdata": + return filepath.SkipDir + } + return nil + } + if filepath.Ext(pathValue) != ".go" || strings.HasSuffix(pathValue, "_test.go") { + return nil + } + parsed, err := parser.ParseFile(token.NewFileSet(), pathValue, nil, 0) + if err != nil { + return err + } + ast.Inspect(parsed, func(node ast.Node) bool { + literal, ok := node.(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(literal.Value) + if err == nil && value == DocumentKind { + owners = append(owners, strings.TrimPrefix(pathValue, repositoryRoot+string(filepath.Separator))) + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } + want := []string{"internal/kernel/requirementsourcecodec/types.go"} + if !reflect.DeepEqual(owners, want) { + t.Fatalf("v2 grammar identity owners = %v, want %v", owners, want) + } +} diff --git a/internal/kernel/requirementsourcecodec/parse.go b/internal/kernel/requirementsourcecodec/parse.go index 992e8c2..c39f9ad 100644 --- a/internal/kernel/requirementsourcecodec/parse.go +++ b/internal/kernel/requirementsourcecodec/parse.go @@ -3,8 +3,6 @@ package requirementsourcecodec import ( "bytes" "encoding/json" - "strconv" - "strings" "unicode/utf8" "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" @@ -25,12 +23,16 @@ func ParseWithLimits(source []byte, codecLimits Limits, modelLimits requirements if invalidOffset, ok := firstInvalidUTF8(source); ok { return Result{}, diagnosticError(source, "invalid_utf8", "", ByteSpan{Start: invalidOffset, End: invalidOffset + 1}, false) } + if err := preflightTokenLimit(source, codecLimits.MaxTokens); err != nil { + return Result{}, err + } - indexed, err := indexJSON(source, codecLimits) + wireShape := documentShape(modelLimits) + indexed, err := indexJSON(source, codecLimits, wireShape) if err != nil { return Result{}, err } - if err := validateShape(indexed.value, documentShape(modelLimits), "", indexed.locations, source); err != nil { + if err := validateShape(indexed.value, wireShape, "", indexed.locations, source); err != nil { return Result{}, err } @@ -50,7 +52,7 @@ func ParseWithLimits(source []byte, codecLimits Limits, modelLimits requirements } model, err := requirementsourcemodel.NormalizeWithLimits(draft, modelLimits) if err != nil { - return Result{}, modelDiagnostic(source, indexed.locations, err) + return Result{}, modelDiagnostic(source, indexed.locations, wire, err) } return Result{Model: model, SourceMap: sourceMap(source, indexed.locations)}, nil } @@ -66,77 +68,13 @@ func firstInvalidUTF8(source []byte) (int64, bool) { return 0, false } -func modelDiagnostic(source []byte, locations map[string]rawLocation, err error) error { +func modelDiagnostic(source []byte, locations map[string]rawLocation, wire document, err error) error { code := requirementsourcemodel.ErrorCode(err) validation, ok := err.(*requirementsourcemodel.ValidationError) if !ok || code == "" { return diagnosticError(source, "model_admission_failed", "", locations[""].value, true) } - lookupPath, reportedPath := modelPathPointers(validation.Path) - location := closestLocation(locations, lookupPath) - return diagnosticError(source, code, reportedPath, location.value, true) -} - -func closestLocation(locations map[string]rawLocation, path string) rawLocation { - for current := path; ; { - if location, exists := locations[current]; exists { - return location - } - index := strings.LastIndex(current, "/") - if index < 0 { - break - } - current = current[:index] - } - return locations[""] -} - -func modelPathPointers(path string) (string, string) { - segments := modelPathSegments(path) - lookup := "" - reported := "" - dynamicEntry := false - for _, segment := range segments { - lookup = joinPointer(lookup, segment) - reportedSegment := segment - if dynamicEntry { - reportedSegment = "" - dynamicEntry = false - } - reported = joinPointer(reported, reportedSegment) - if segment == "values" { - dynamicEntry = true - } - } - return lookup, reported -} - -func modelPathSegments(path string) []string { - segments := make([]string, 0, 8) - for offset := 0; offset < len(path); { - switch path[offset] { - case '.': - offset++ - case '[': - end := strings.IndexByte(path[offset:], ']') - if end < 0 { - return segments - } - end += offset - if _, err := strconv.Atoi(path[offset+1 : end]); err == nil { - segments = append(segments, path[offset+1:end]) - } - offset = end + 1 - default: - end := offset - for end < len(path) && path[end] != '.' && path[end] != '[' { - end++ - } - if end > offset { - segments = append(segments, path[offset:end]) - } - offset = end - } - } - return segments + resolved := resolveModelPath(wire, validation.Path) + location := closestLocation(locations, resolved.lookup) + return diagnosticError(source, code, resolved.reported, location.value, true) } diff --git a/internal/kernel/requirementsourcecodec/roundtrip_test.go b/internal/kernel/requirementsourcecodec/roundtrip_test.go index bc84db6..78ef970 100644 --- a/internal/kernel/requirementsourcecodec/roundtrip_test.go +++ b/internal/kernel/requirementsourcecodec/roundtrip_test.go @@ -99,3 +99,26 @@ func TestReturnedSourceMapIsImmutable(t *testing.T) { t.Fatal("caller mutation escaped into source-map owner state") } } + +func TestSourceMapIndexesLexicalWireOrderNotNormalizedOrder(t *testing.T) { + payload := mutateRoot(t, mustPayload(t), func(root map[string]any) { + groups := root["groups"].([]any) + for left, right := 0, len(groups)-1; left < right; left, right = left+1, right-1 { + groups[left], groups[right] = groups[right], groups[left] + } + }) + result, err := Parse(payload) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + location, ok := result.SourceMap.Location("/groups/0/groupId") + if !ok { + t.Fatal("source map lacks lexical first group") + } + if got := payload[location.ValueSpan.Start:location.ValueSpan.End]; !bytes.Equal(got, []byte(`"RGRP-CODEC-SUPERSEDED"`)) { + t.Fatalf("lexical first group = %q", got) + } + if result.Model.Layout().Groups[0].GroupID != "RGRP-CODEC-DEFERRED" { + t.Fatal("model projection did not retain its independent normalized order") + } +} diff --git a/internal/kernel/requirementsourcecodec/selection_evaluator_test.go b/internal/kernel/requirementsourcecodec/selection_evaluator_test.go index 87f94e5..76c78d2 100644 --- a/internal/kernel/requirementsourcecodec/selection_evaluator_test.go +++ b/internal/kernel/requirementsourcecodec/selection_evaluator_test.go @@ -21,14 +21,17 @@ func selectJSONLayout(record codecSelection) string { } } sort.Slice(eligible, func(left, right int) bool { - leftKey := []int{eligible[left].WeightedTokensO200kBase, eligible[left].WeightedCanonicalBytes, eligible[left].ChangedBytes, eligible[left].ChangedLines} - rightKey := []int{eligible[right].WeightedTokensO200kBase, eligible[right].WeightedCanonicalBytes, eligible[right].ChangedBytes, eligible[right].ChangedLines} - for index := range leftKey { - if leftKey[index] != rightKey[index] { - return leftKey[index] < rightKey[index] + for _, metricID := range record.JSONLayoutOrder { + if metricID == "candidate_id" { + return eligible[left].CandidateID < eligible[right].CandidateID + } + leftValue := layoutMetricValue(eligible[left], metricID) + rightValue := layoutMetricValue(eligible[right], metricID) + if leftValue != rightValue { + return leftValue < rightValue } } - return eligible[left].CandidateID < eligible[right].CandidateID + return false }) if len(eligible) == 0 { return "" @@ -53,13 +56,29 @@ func challengerEligible(record codecSelection) bool { challenger.EditLocality && materiallyBetter(baseline.WeightedCanonicalBytes, challenger.WeightedCanonicalBytes, policy.MinimumByteImprovementBasisPoints) && materiallyBetter(baseline.WeightedTokensO200kBase, challenger.WeightedTokensO200kBase, policy.MinimumTokenImprovementBasisPoints) && - challenger.AggregateDiffState == "passed" && challenger.PerEditDiffState == "passed" && + challenger.AggregateDiffRegressionBasisPoints != nil && *challenger.AggregateDiffRegressionBasisPoints <= policy.MaximumAggregateDiffRegressionBasisPoints && + challenger.PerEditDiffRegressionBasisPoints != nil && *challenger.PerEditDiffRegressionBasisPoints <= policy.MaximumPerEditDiffRegressionBasisPoints && challenger.ParseTimeState == "passed" && challenger.FormatTimeState == "passed" && challenger.LowerCostDominanceState == "passed" && withinRatio(baseline.ProjectedProductionLOC, challenger.ProjectedProductionLOC, policy.MaximumProjectedProductionCostBasisPoints) && withinRatio(baseline.ProjectedProductionBranches, challenger.ProjectedProductionBranches, policy.MaximumProjectedProductionCostBasisPoints) } +func layoutMetricValue(value screenObservation, metricID string) int { + switch metricID { + case "weighted_tokens_o200k_base": + return value.WeightedTokensO200kBase + case "weighted_canonical_bytes": + return value.WeightedCanonicalBytes + case "changed_bytes": + return value.ChangedBytes + case "changed_lines": + return value.ChangedLines + default: + panic("unknown JSON layout metric") + } +} + func observationByID(observations []screenObservation, candidateID string) (screenObservation, bool) { for _, observation := range observations { if observation.CandidateID == candidateID { @@ -108,8 +127,10 @@ func TestChallengerEligibilityRequiresEveryReplacementPredicate(t *testing.T) { {name: "edit locality", mutate: challengerMutation(func(item *screenObservation) { item.EditLocality = false })}, {name: "material byte improvement", mutate: challengerMutation(func(item *screenObservation) { item.WeightedCanonicalBytes = 2605964 })}, {name: "material token improvement", mutate: challengerMutation(func(item *screenObservation) { item.WeightedTokensO200kBase = 698724 })}, - {name: "aggregate diff", mutate: challengerMutation(func(item *screenObservation) { item.AggregateDiffState = "failed" })}, - {name: "per-edit diff", mutate: challengerMutation(func(item *screenObservation) { item.PerEditDiffState = "failed" })}, + {name: "aggregate diff present", mutate: challengerMutation(func(item *screenObservation) { item.AggregateDiffRegressionBasisPoints = nil })}, + {name: "aggregate diff threshold", mutate: challengerMutation(func(item *screenObservation) { item.AggregateDiffRegressionBasisPoints = integerPointer(501) })}, + {name: "per-edit diff present", mutate: challengerMutation(func(item *screenObservation) { item.PerEditDiffRegressionBasisPoints = nil })}, + {name: "per-edit diff threshold", mutate: challengerMutation(func(item *screenObservation) { item.PerEditDiffRegressionBasisPoints = integerPointer(1501) })}, {name: "parse time", mutate: challengerMutation(func(item *screenObservation) { item.ParseTimeState = "missing" })}, {name: "format time", mutate: challengerMutation(func(item *screenObservation) { item.FormatTimeState = "missing" })}, {name: "lower-cost dominance", mutate: challengerMutation(func(item *screenObservation) { item.LowerCostDominanceState = "failed" })}, @@ -141,8 +162,8 @@ func eligibleChallengerRecord(t *testing.T) codecSelection { item.WeightedTokensO200kBase = 620_000 item.ProjectedProductionLOC = 600 item.ProjectedProductionBranches = 60 - item.AggregateDiffState = "passed" - item.PerEditDiffState = "passed" + item.AggregateDiffRegressionBasisPoints = integerPointer(0) + item.PerEditDiffRegressionBasisPoints = integerPointer(0) item.ParseTimeState = "passed" item.FormatTimeState = "passed" item.LowerCostDominanceState = "passed" diff --git a/internal/kernel/requirementsourcecodec/selection_evidence_test.go b/internal/kernel/requirementsourcecodec/selection_evidence_test.go new file mode 100644 index 0000000..9c56976 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/selection_evidence_test.go @@ -0,0 +1,429 @@ +package requirementsourcecodec + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "path" + "reflect" + "sort" + "strings" + "testing" + "testing/fstest" +) + +const ( + maxScreenArchiveBytes = 8 << 20 + maxScreenFileBytes = 4 << 20 + maxScreenFileCount = 512 + maxScreenUncompressedBytes = 16 << 20 +) + +type boundFile struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` +} + +type screenManifestEvidence struct { + SchemaVersion int `json:"schemaVersion"` + Kind string `json:"kind"` + FixtureGenerator boundFile `json:"fixtureGenerator"` + Renderer boundFile `json:"renderer"` + SelectionOpener boundFile `json:"selectionOpener"` + MethodBindings []boundFile `json:"methodBindings"` + Review struct { + Assignment boundFile `json:"assignment"` + } `json:"review"` +} + +type fixtureIndexEvidence struct { + SchemaVersion int `json:"schemaVersion"` + Kind string `json:"kind"` + Fixtures []struct { + Path string `json:"path"` + SemanticSHA256 string `json:"semanticSha256"` + } `json:"fixtures"` + Edits []struct { + BeforePath string `json:"beforePath"` + BeforeSHA256 string `json:"beforeSha256"` + AfterPath string `json:"afterPath"` + AfterSHA256 string `json:"afterSha256"` + } `json:"edits"` +} + +type tokenReportEvidence struct { + Implementation string `json:"implementation"` + Records []struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Tokens int `json:"tokens"` + } `json:"records"` +} + +type screenDecisionEvidence struct { + SchemaVersion int `json:"schemaVersion"` + Kind string `json:"kind"` + ManifestSHA256 string `json:"manifestSha256"` + ObservationSHA256 string `json:"observationSha256"` + OpeningSHA256 string `json:"openingSha256"` + ReviewResultsSHA256 string `json:"reviewResultsSha256"` + TokenReportSHA256 map[string]string `json:"tokenReportSha256"` + ValidationSHA256 string `json:"validationSha256"` + State string `json:"state"` + SelectedJSONLayout string `json:"selectedJsonLayout"` + SelectedChallenger *string `json:"selectedChallenger"` + Candidates []struct { + CandidateID string `json:"candidateId"` + WeightedCanonicalBytes int `json:"weightedCanonicalBytes"` + WeightedTokensO200kBase int `json:"weightedTokensO200kBase"` + ChangedLines int `json:"changedLines"` + ChangedBytes int `json:"changedBytes"` + EditLocality bool `json:"editLocality"` + JSONFieldClosure string `json:"jsonFieldClosure"` + Review *struct { + AccuracyBasisPoints int `json:"accuracyBasisPoints"` + InvalidMutationFalseAccepts int `json:"invalidMutationFalseAccepts"` + } `json:"review"` + ProjectedProduction struct { + LOC int `json:"loc"` + Branches int `json:"branches"` + } `json:"projectedProduction"` + } `json:"candidates"` +} + +func TestSelectionEvidenceIsByteBoundAndProjectsDecision(t *testing.T) { + record := readCodecSelection(t) + root := readScreenArchive(t, record.ScreenEvidence) + if err := verifyScreenTree(root, record.ScreenEvidence.ArtifactCount, record.ScreenEvidence.TreeSHA256); err != nil { + t.Fatal(err) + } + artifacts := verifyScreenArtifacts(t, root, record.ScreenEvidence.Artifacts) + manifest := readEvidence[screenManifestEvidence](t, root, artifacts["screen-manifest"].Path) + if manifest.SchemaVersion != 3 || manifest.Kind != "proofkit.requirement-source-codec-formatter-screen" { + t.Fatalf("screen manifest identity = %#v", manifest) + } + bound := []boundFile{manifest.FixtureGenerator, manifest.Renderer, manifest.SelectionOpener, manifest.Review.Assignment} + bound = append(bound, manifest.MethodBindings...) + for _, file := range bound { + assertBoundFile(t, root, file) + } + + fixtureIndex := readEvidence[fixtureIndexEvidence](t, root, artifacts["fixture-index"].Path) + if fixtureIndex.SchemaVersion != 1 || fixtureIndex.Kind != "proofkit.requirement-source-codec-selection-fixtures" { + t.Fatalf("fixture index identity = %#v", fixtureIndex) + } + for _, fixture := range fixtureIndex.Fixtures { + assertBoundFile(t, root, boundFile{Path: path.Join("out-v3", fixture.Path), SHA256: fixture.SemanticSHA256}) + } + for _, edit := range fixtureIndex.Edits { + assertBoundFile(t, root, boundFile{Path: path.Join("out-v3", edit.BeforePath), SHA256: edit.BeforeSHA256}) + assertBoundFile(t, root, boundFile{Path: path.Join("out-v3", edit.AfterPath), SHA256: edit.AfterSHA256}) + } + + pythonTokens := readEvidence[tokenReportEvidence](t, root, artifacts["token-python"].Path) + goTokens := readEvidence[tokenReportEvidence](t, root, artifacts["token-go"].Path) + if pythonTokens.Implementation != "openai-tiktoken-0.14.0" || goTokens.Implementation != "tiktoken-go-0.8.1" || !reflect.DeepEqual(pythonTokens.Records, goTokens.Records) { + t.Fatal("independent token reports are not exactly equal") + } + for _, token := range pythonTokens.Records { + assertBoundFile(t, root, boundFile{Path: path.Join("out-v3", "rendered", token.Path), SHA256: token.SHA256}) + if token.Tokens <= 0 { + t.Fatalf("nonpositive token count for %q", token.Path) + } + } + + decision := readEvidence[screenDecisionEvidence](t, root, artifacts["screen-decision"].Path) + assertDecisionLinks(t, decision, artifacts) + assertDecisionProjection(t, record, decision) +} + +func TestScreenTreeDigestRejectsByteAndInventoryMutation(t *testing.T) { + original := fstest.MapFS{"a": {Data: []byte("alpha")}, "nested/b": {Data: []byte("beta")}} + digest, count, err := screenTreeDigest(original) + if err != nil { + t.Fatal(err) + } + if err := verifyScreenTree(original, count, digest); err != nil { + t.Fatal(err) + } + mutated := fstest.MapFS{"a": {Data: []byte("changed")}, "nested/b": {Data: []byte("beta")}} + if err := verifyScreenTree(mutated, count, digest); err == nil { + t.Fatal("screen evidence byte mutation retained the original identity") + } + deleted := fstest.MapFS{"a": {Data: []byte("alpha")}} + if err := verifyScreenTree(deleted, count, digest); err == nil { + t.Fatal("screen evidence inventory mutation retained the original identity") + } +} + +func TestScreenArchiveAdmissionRejectsUnsafeTopology(t *testing.T) { + tests := []struct { + name string + entries []tar.Header + want string + }{ + {name: "traversal", entries: []tar.Header{{Name: "../escape", Mode: 0o444, Size: 1, Typeflag: tar.TypeReg}}, want: "unsafe screen archive entry"}, + {name: "symlink", entries: []tar.Header{{Name: "link", Linkname: "../escape", Mode: 0o777, Typeflag: tar.TypeSymlink}}, want: "unsupported screen archive entry type"}, + {name: "duplicate", entries: []tar.Header{{Name: "entry", Mode: 0o444, Size: 1, Typeflag: tar.TypeReg}, {Name: "entry", Mode: 0o444, Size: 1, Typeflag: tar.TypeReg}}, want: "duplicate screen archive entry"}, + } + for _, item := range tests { + t.Run(item.name, func(t *testing.T) { + _, err := decodeScreenArchive(screenArchiveFixture(t, item.entries)) + if err == nil || !strings.Contains(err.Error(), item.want) { + t.Fatalf("decodeScreenArchive() error = %v, want %q", err, item.want) + } + }) + } +} + +func verifyScreenArtifacts(t *testing.T, root fs.FS, values []screenArtifact) map[string]screenArtifact { + t.Helper() + result := make(map[string]screenArtifact, len(values)) + for _, artifact := range values { + assertBoundFile(t, root, boundFile{Path: artifact.Path, SHA256: artifact.SHA256}) + if _, duplicate := result[artifact.Role]; duplicate { + t.Fatalf("duplicate screen artifact role %q", artifact.Role) + } + result[artifact.Role] = artifact + } + return result +} + +func assertDecisionLinks(t *testing.T, value screenDecisionEvidence, artifacts map[string]screenArtifact) { + t.Helper() + if value.SchemaVersion != 3 || value.Kind != "proofkit.requirement-source-codec-screen-decision" { + t.Fatalf("screen decision identity = %#v", value) + } + want := map[string]string{ + "screen-manifest": value.ManifestSHA256, + "observations": value.ObservationSHA256, + "selection-opening": value.OpeningSHA256, + "review-results": value.ReviewResultsSHA256, + "independent-validation": value.ValidationSHA256, + "token-python": value.TokenReportSHA256["openai-tiktoken-0.14.0"], + "token-go": value.TokenReportSHA256["tiktoken-go-0.8.1"], + } + for role, digest := range want { + if artifacts[role].SHA256 != digest { + t.Fatalf("decision link %q = %q, want %q", role, digest, artifacts[role].SHA256) + } + } +} + +func assertDecisionProjection(t *testing.T, record codecSelection, decision screenDecisionEvidence) { + t.Helper() + if decision.State != record.Decision.State || decision.SelectedJSONLayout != record.Decision.SelectedJSONLayout || !reflect.DeepEqual(decision.SelectedChallenger, record.Decision.SelectedChallenger) { + t.Fatalf("screen decision projection mismatch: %#v", decision) + } + byID := make(map[string]screenObservation, len(record.ScreenObservations)) + for _, item := range record.ScreenObservations { + byID[item.CandidateID] = item + } + seen := make(map[string]struct{}, len(decision.Candidates)) + for _, candidate := range decision.Candidates { + if _, duplicate := seen[candidate.CandidateID]; duplicate { + t.Fatalf("decision repeats candidate %q", candidate.CandidateID) + } + seen[candidate.CandidateID] = struct{}{} + observation, exists := byID[candidate.CandidateID] + if !exists { + t.Fatalf("decision contains unknown candidate %q", candidate.CandidateID) + } + if observation.WeightedCanonicalBytes != candidate.WeightedCanonicalBytes || observation.WeightedTokensO200kBase != candidate.WeightedTokensO200kBase || + observation.ChangedLines != candidate.ChangedLines || observation.ChangedBytes != candidate.ChangedBytes || observation.EditLocality != candidate.EditLocality || + observation.ProjectedProductionLOC != candidate.ProjectedProduction.LOC || observation.ProjectedProductionBranches != candidate.ProjectedProduction.Branches { + t.Fatalf("candidate %q observation drift", candidate.CandidateID) + } + if candidate.JSONFieldClosure != "" && observation.FieldClosure != candidate.JSONFieldClosure { + t.Fatalf("candidate %q field closure drift", candidate.CandidateID) + } + if candidate.Review != nil && (observation.ReviewAccuracyBasisPoints == nil || observation.InvalidMutationFalseAccepts == nil || + *observation.ReviewAccuracyBasisPoints != candidate.Review.AccuracyBasisPoints || *observation.InvalidMutationFalseAccepts != candidate.Review.InvalidMutationFalseAccepts) { + t.Fatalf("candidate %q review projection drift", candidate.CandidateID) + } + if candidate.Review == nil && (observation.ReviewAccuracyBasisPoints != nil || observation.InvalidMutationFalseAccepts != nil) { + t.Fatalf("candidate %q unexpected review projection", candidate.CandidateID) + } + } + if len(decision.Candidates) != len(record.ScreenObservations) { + t.Fatalf("decision candidate count = %d, want %d", len(decision.Candidates), len(record.ScreenObservations)) + } +} + +func assertBoundFile(t *testing.T, root fs.FS, value boundFile) { + t.Helper() + if value.Path == "" || path.IsAbs(value.Path) || path.Clean(value.Path) != value.Path || value.Path == ".." || len(value.Path) > 3 && value.Path[:3] == "../" { + t.Fatalf("unsafe bound evidence path %q", value.Path) + } + payload, err := fs.ReadFile(root, value.Path) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(payload) + if hex.EncodeToString(digest[:]) != value.SHA256 { + t.Fatalf("evidence digest mismatch for %q", value.Path) + } +} + +func readEvidence[T any](t *testing.T, root fs.FS, filePath string) T { + t.Helper() + payload, err := fs.ReadFile(root, filePath) + if err != nil { + t.Fatal(err) + } + var result T + if err := json.Unmarshal(payload, &result); err != nil { + t.Fatal(err) + } + return result +} + +func readScreenArchive(t *testing.T, evidence screenEvidence) fstest.MapFS { + t.Helper() + payload, err := os.ReadFile(evidence.ArchivePath) + if err != nil { + t.Fatal(err) + } + if len(payload) > maxScreenArchiveBytes { + t.Fatalf("screen archive bytes = %d, limit = %d", len(payload), maxScreenArchiveBytes) + } + digest := sha256.Sum256(payload) + if hex.EncodeToString(digest[:]) != evidence.ArchiveSHA256 { + t.Fatal("screen archive digest mismatch") + } + result, err := decodeScreenArchive(payload) + if err != nil { + t.Fatal(err) + } + return result +} + +func decodeScreenArchive(payload []byte) (fstest.MapFS, error) { + compressed, err := gzip.NewReader(bytes.NewReader(payload)) + if err != nil { + return nil, err + } + compressed.Multistream(false) + defer compressed.Close() + + result := fstest.MapFS{} + totalBytes := int64(0) + archive := tar.NewReader(compressed) + for { + header, err := archive.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + clean := path.Clean(header.Name) + if header.Name == "" || clean != header.Name || clean == "." || path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") { + return nil, fmt.Errorf("unsafe screen archive entry %q", header.Name) + } + if header.Typeflag == tar.TypeDir { + continue + } + if header.Typeflag != tar.TypeReg { + return nil, fmt.Errorf("unsupported screen archive entry type %d for %q", header.Typeflag, header.Name) + } + if header.Size < 0 || header.Size > maxScreenFileBytes { + return nil, fmt.Errorf("screen archive entry %q size = %d", header.Name, header.Size) + } + if _, duplicate := result[clean]; duplicate { + return nil, fmt.Errorf("duplicate screen archive entry %q", clean) + } + if len(result) >= maxScreenFileCount || totalBytes+header.Size > maxScreenUncompressedBytes { + return nil, fmt.Errorf("screen archive exceeds admitted expansion limits") + } + content, err := io.ReadAll(io.LimitReader(archive, header.Size+1)) + if err != nil { + return nil, err + } + if int64(len(content)) != header.Size { + return nil, fmt.Errorf("screen archive entry %q size mismatch", header.Name) + } + totalBytes += header.Size + result[clean] = &fstest.MapFile{Data: content, Mode: 0o444} + } + return result, nil +} + +func screenArchiveFixture(t *testing.T, headers []tar.Header) []byte { + t.Helper() + var result bytes.Buffer + compressed := gzip.NewWriter(&result) + archive := tar.NewWriter(compressed) + for index := range headers { + header := headers[index] + if err := archive.WriteHeader(&header); err != nil { + t.Fatal(err) + } + if header.Size > 0 { + if _, err := archive.Write(bytes.Repeat([]byte{'x'}, int(header.Size))); err != nil { + t.Fatal(err) + } + } + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := compressed.Close(); err != nil { + t.Fatal(err) + } + return result.Bytes() +} + +func verifyScreenTree(root fs.FS, expectedCount int, expectedDigest string) error { + digest, count, err := screenTreeDigest(root) + if err != nil { + return err + } + if count != expectedCount || digest != expectedDigest { + return fmt.Errorf("screen evidence tree identity mismatch: got count=%d digest=%s, want count=%d digest=%s", count, digest, expectedCount, expectedDigest) + } + return nil +} + +func screenTreeDigest(root fs.FS) (string, int, error) { + paths := []string{} + if err := fs.WalkDir(root, ".", func(filePath string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if !entry.IsDir() { + paths = append(paths, filePath) + } + return nil + }); err != nil { + return "", 0, err + } + sort.Strings(paths) + tree := sha256.New() + for _, filePath := range paths { + payload, err := fs.ReadFile(root, filePath) + if err != nil { + return "", 0, err + } + fileDigest := sha256.Sum256(payload) + tree.Write([]byte(filePath)) + tree.Write([]byte{0}) + tree.Write([]byte(hex.EncodeToString(fileDigest[:]))) + tree.Write([]byte{'\n'}) + } + return hex.EncodeToString(tree.Sum(nil)), len(paths), nil +} + +func TestScreenTreeDigestAlgorithmMatchesDeclaredBytes(t *testing.T) { + record := readCodecSelection(t) + if !bytes.Equal([]byte(record.ScreenEvidence.TreeDigestAlgorithm), []byte("sha256(sorted(relative-path NUL file-sha256 LF))")) { + t.Fatal("screen tree digest algorithm drift") + } +} diff --git a/internal/kernel/requirementsourcecodec/selection_test.go b/internal/kernel/requirementsourcecodec/selection_test.go index 2f43f94..b04aa5e 100644 --- a/internal/kernel/requirementsourcecodec/selection_test.go +++ b/internal/kernel/requirementsourcecodec/selection_test.go @@ -3,6 +3,7 @@ package requirementsourcecodec import ( "bytes" "encoding/json" + "fmt" "go/ast" "go/parser" "go/token" @@ -10,6 +11,7 @@ import ( "path/filepath" "reflect" "sort" + "strings" "testing" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" @@ -21,6 +23,7 @@ type codecSelection struct { Kind string `json:"kind"` ScreenEvidence screenEvidence `json:"screenEvidence"` Roles selectionRoles `json:"roles"` + JSONLayoutOrder []string `json:"jsonLayoutOrder"` MetricRegistry []selectionMetric `json:"metricRegistry"` ReplacementPolicy replacementPolicy `json:"replacementPolicy"` ScreenObservations []screenObservation `json:"screenObservations"` @@ -30,14 +33,20 @@ type codecSelection struct { } type screenEvidence struct { - ScreenManifestVersion int `json:"screenManifestVersion"` - ScreenManifestSHA256 string `json:"screenManifestSha256"` - SelectionOpeningSHA256 string `json:"selectionOpeningSha256"` - FixtureIndexSHA256 string `json:"fixtureIndexSha256"` - ObservationsSHA256 string `json:"observationsSha256"` - IndependentValidationSHA256 string `json:"independentValidationSha256"` - ReviewResultsSHA256 string `json:"reviewResultsSha256"` - TokenProducerDigests map[string]string `json:"tokenProducerDigests"` + ArchiveFormat string `json:"archiveFormat"` + ArchivePath string `json:"archivePath"` + ArchiveSHA256 string `json:"archiveSha256"` + RootPath string `json:"rootPath"` + TreeDigestAlgorithm string `json:"treeDigestAlgorithm"` + TreeSHA256 string `json:"treeSha256"` + ArtifactCount int `json:"artifactCount"` + Artifacts []screenArtifact `json:"artifacts"` +} + +type screenArtifact struct { + Role string `json:"role"` + Path string `json:"path"` + SHA256 string `json:"sha256"` } type selectionRoles struct { @@ -72,22 +81,22 @@ type replacementPolicy struct { } type screenObservation struct { - CandidateID string `json:"candidateId"` - FieldClosure string `json:"fieldClosure"` - ReviewAccuracyBasisPoints *int `json:"reviewAccuracyBasisPoints"` - InvalidMutationFalseAccepts *int `json:"invalidMutationFalseAccepts"` - EditLocality bool `json:"editLocality"` - WeightedCanonicalBytes int `json:"weightedCanonicalBytes"` - WeightedTokensO200kBase int `json:"weightedTokensO200kBase"` - ChangedLines int `json:"changedLines"` - ChangedBytes int `json:"changedBytes"` - ProjectedProductionLOC int `json:"projectedProductionLoc"` - ProjectedProductionBranches int `json:"projectedProductionBranches"` - AggregateDiffState string `json:"aggregateDiffState"` - PerEditDiffState string `json:"perEditDiffState"` - ParseTimeState string `json:"parseTimeState"` - FormatTimeState string `json:"formatTimeState"` - LowerCostDominanceState string `json:"lowerCostDominanceState"` + CandidateID string `json:"candidateId"` + FieldClosure string `json:"fieldClosure"` + ReviewAccuracyBasisPoints *int `json:"reviewAccuracyBasisPoints"` + InvalidMutationFalseAccepts *int `json:"invalidMutationFalseAccepts"` + EditLocality bool `json:"editLocality"` + WeightedCanonicalBytes int `json:"weightedCanonicalBytes"` + WeightedTokensO200kBase int `json:"weightedTokensO200kBase"` + ChangedLines int `json:"changedLines"` + ChangedBytes int `json:"changedBytes"` + ProjectedProductionLOC int `json:"projectedProductionLoc"` + ProjectedProductionBranches int `json:"projectedProductionBranches"` + AggregateDiffRegressionBasisPoints *int `json:"aggregateDiffRegressionBasisPoints"` + PerEditDiffRegressionBasisPoints *int `json:"perEditDiffRegressionBasisPoints"` + ParseTimeState string `json:"parseTimeState"` + FormatTimeState string `json:"formatTimeState"` + LowerCostDominanceState string `json:"lowerCostDominanceState"` } type selectionDecision struct { @@ -100,10 +109,11 @@ type selectionDecision struct { func TestSelectionRecordIsClosedAndDecisionIsReproducible(t *testing.T) { record := readCodecSelection(t) - if record.SchemaVersion != 1 || record.Kind != "proofkit.requirement-source-codec-selection" || record.ScreenEvidence.ScreenManifestVersion != 3 { + if record.SchemaVersion != 1 || record.Kind != "proofkit.requirement-source-codec-selection" { t.Fatalf("selection identity = %#v", record) } - assertScreenDigests(t, record.ScreenEvidence) + assertScreenEvidenceMetadata(t, record.ScreenEvidence) + assertJSONLayoutOrder(t, record.JSONLayoutOrder) assertSortedUniqueMetricRegistry(t, record.MetricRegistry) assertReplacementPolicy(t, record.ReplacementPolicy) assertDisjointRoles(t, record.Roles) @@ -145,28 +155,42 @@ func readCodecSelection(t *testing.T) codecSelection { return record } -func assertScreenDigests(t *testing.T, value screenEvidence) { +func assertScreenEvidenceMetadata(t *testing.T, value screenEvidence) { t.Helper() - digests := []string{ - value.ScreenManifestSHA256, value.SelectionOpeningSHA256, value.FixtureIndexSHA256, - value.ObservationsSHA256, value.IndependentValidationSHA256, value.ReviewResultsSHA256, + if value.ArchiveFormat != "tar+gzip" || value.ArchivePath != "testdata/screen-v3.tgz" || value.RootPath != "." || value.TreeDigestAlgorithm != "sha256(sorted(relative-path NUL file-sha256 LF))" || value.ArtifactCount != 197 { + t.Fatalf("screen evidence root = %#v", value) + } + pathClean := filepath.ToSlash(filepath.Clean(value.ArchivePath)) + if filepath.IsAbs(value.ArchivePath) || pathClean != value.ArchivePath || strings.HasPrefix(pathClean, "../") { + t.Fatalf("unsafe screen archive path %q", value.ArchivePath) } - for _, digest := range value.TokenProducerDigests { - digests = append(digests, digest) + if _, err := admit.LowercaseSHA256(value.ArchiveSHA256, "archiveSha256"); err != nil { + t.Fatalf("invalid screen archive digest %q", value.ArchiveSHA256) } - for _, digest := range digests { - if _, err := admit.LowercaseSHA256(digest, "digest"); err != nil { - t.Fatalf("invalid screen digest %q", digest) + if _, err := admit.LowercaseSHA256(value.TreeSHA256, "treeSha256"); err != nil { + t.Fatalf("invalid screen tree digest %q", value.TreeSHA256) + } + wantRoles := []string{"fixture-index", "independent-validation", "observations", "review-results", "screen-decision", "screen-manifest", "selection-opening", "token-go", "token-python"} + roles := make([]string, len(value.Artifacts)) + for index, artifact := range value.Artifacts { + roles[index] = artifact.Role + if artifact.Path == "" || filepath.IsAbs(artifact.Path) || strings.Contains(artifact.Path, "..") { + t.Fatalf("unsafe screen artifact path %q", artifact.Path) + } + if _, err := admit.LowercaseSHA256(artifact.SHA256, artifact.Role); err != nil { + t.Fatalf("invalid screen artifact digest %q", artifact.SHA256) } } - wantProducers := []string{"openai-tiktoken-0.14.0", "tiktoken-go-0.8.1"} - actualProducers := make([]string, 0, len(value.TokenProducerDigests)) - for producer := range value.TokenProducerDigests { - actualProducers = append(actualProducers, producer) + if !reflect.DeepEqual(roles, wantRoles) { + t.Fatalf("screen artifact roles = %v, want %v", roles, wantRoles) } - sort.Strings(actualProducers) - if !reflect.DeepEqual(actualProducers, wantProducers) { - t.Fatalf("token producers = %v, want %v", actualProducers, wantProducers) +} + +func assertJSONLayoutOrder(t *testing.T, order []string) { + t.Helper() + want := []string{"weighted_tokens_o200k_base", "weighted_canonical_bytes", "changed_bytes", "changed_lines", "candidate_id"} + if !reflect.DeepEqual(order, want) { + t.Fatalf("JSON layout order = %v, want %v", order, want) } } @@ -190,17 +214,17 @@ func assertSortedUniqueMetricRegistry(t *testing.T, metrics []selectionMetric) { } } wantIDs := []string{ - "changed_bytes", "changed_lines", "edit_locality", "field_closure", "format_time", - "invalid_mutation_false_accepts", "parse_time", "projected_production_branches", - "projected_production_loc", "review_accuracy_basis_points", "weighted_canonical_bytes", - "weighted_tokens_o200k_base", + "aggregate_diff_regression_basis_points", "changed_bytes", "changed_lines", "edit_locality", "field_closure", "format_time_state", + "invalid_mutation_false_accepts", "lower_cost_dominance_state", "parse_time_state", "per_edit_diff_regression_basis_points", + "projected_production_branches", "projected_production_loc", "review_accuracy_basis_points", + "weighted_canonical_bytes", "weighted_tokens_o200k_base", } if !reflect.DeepEqual(ids, wantIDs) { t.Fatalf("metric IDs = %v, want exact registry %v", ids, wantIDs) } for _, metric := range metrics { - if metric.MetricID == "parse_time" || metric.MetricID == "format_time" { - if metric.Stage != "replacement" || metric.Role != "primary" || metric.Direction != "minimize" || metric.Missing != "reject" || metric.Requirement != "noninferior" { + if metric.Stage == "replacement" { + if metric.Role != "hard" || metric.Missing != "reject" { t.Fatalf("replacement performance metric = %#v", metric) } } else if metric.Stage != "screen" { @@ -284,18 +308,23 @@ func assertObservationClosure(t *testing.T, record codecSelection) { if observation.InvalidMutationFalseAccepts != nil && *observation.InvalidMutationFalseAccepts < 0 { t.Fatalf("candidate %q has invalid false-accept count", observation.CandidateID) } - states := []string{observation.AggregateDiffState, observation.PerEditDiffState, observation.ParseTimeState, observation.FormatTimeState, observation.LowerCostDominanceState} + states := []string{observation.ParseTimeState, observation.FormatTimeState, observation.LowerCostDominanceState} for _, state := range states { if !allowedState[state] { t.Fatalf("candidate %q has invalid replacement state %q", observation.CandidateID, state) } } if _, isChallenger := challengers[observation.CandidateID]; !isChallenger { + if observation.AggregateDiffRegressionBasisPoints != nil || observation.PerEditDiffRegressionBasisPoints != nil { + t.Fatalf("non-challenger %q has replacement diff observations", observation.CandidateID) + } for _, state := range states { if state != "not_applicable" { t.Fatalf("non-challenger %q has replacement state %q", observation.CandidateID, state) } } + } else if observation.AggregateDiffRegressionBasisPoints == nil || observation.PerEditDiffRegressionBasisPoints == nil || *observation.AggregateDiffRegressionBasisPoints < 0 || *observation.PerEditDiffRegressionBasisPoints < 0 { + t.Fatalf("challenger %q lacks bounded diff observations", observation.CandidateID) } } if !reflect.DeepEqual(actual, want) { @@ -303,6 +332,36 @@ func assertObservationClosure(t *testing.T, record codecSelection) { } } +func (value *screenObservation) UnmarshalJSON(payload []byte) error { + type alias screenObservation + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + var decoded alias + if err := decoder.Decode(&decoded); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(payload, &fields); err != nil { + return err + } + required := []string{ + "candidateId", "fieldClosure", "reviewAccuracyBasisPoints", "invalidMutationFalseAccepts", "editLocality", + "weightedCanonicalBytes", "weightedTokensO200kBase", "changedLines", "changedBytes", "projectedProductionLoc", + "projectedProductionBranches", "aggregateDiffRegressionBasisPoints", "perEditDiffRegressionBasisPoints", + "parseTimeState", "formatTimeState", "lowerCostDominanceState", + } + if len(fields) != len(required) { + return fmt.Errorf("screen observation must contain exactly %d fields", len(required)) + } + for _, field := range required { + if _, exists := fields[field]; !exists { + return fmt.Errorf("screen observation is missing %s", field) + } + } + *value = screenObservation(decoded) + return nil +} + func stringSet(values []string) map[string]struct{} { result := make(map[string]struct{}, len(values)) for _, value := range values { @@ -313,15 +372,29 @@ func stringSet(values []string) map[string]struct{} { func assertHardGateSelectorsExact(t *testing.T, selectors []string) { t.Helper() - want := []string{ - "TestFieldManifestMatchesWireDTOAndClosedShape", - "TestFormatParseRoundTripPreservesEveryProjection", - "TestCanonicalFormatIsIdempotent", - "TestChallengerEligibilityRequiresEveryReplacementPredicate", - "TestCodecMutantManifestClosesRepresentationFailures", - "TestParseDiagnosticsDoNotDiscloseCallerTextOrDynamicKeys", - "TestRawByteBoundaryIsExactAndDominatesUTF8", - "TestHybridLayoutKeepsStableSiblingEntityLinesUnchanged", + payload, err := os.ReadFile(filepath.Join("..", "..", "..", "proofkit", "requirement-bindings.json")) + if err != nil { + t.Fatal(err) + } + var bindings struct { + Bindings []struct { + RequirementID string `json:"requirementId"` + WitnessSelectors []struct { + Selector string `json:"selector"` + } `json:"witnessSelectors"` + } `json:"bindings"` + } + if err := json.Unmarshal(payload, &bindings); err != nil { + t.Fatal(err) + } + want := []string{} + for _, binding := range bindings.Bindings { + if binding.RequirementID != "REQ-PROOFKIT-SPEC-025" { + continue + } + for _, witness := range binding.WitnessSelectors { + want = append(want, witness.Selector) + } } if !reflect.DeepEqual(selectors, want) { t.Fatalf("hard-gate selectors = %v, want %v", selectors, want) diff --git a/internal/kernel/requirementsourcecodec/shape.go b/internal/kernel/requirementsourcecodec/shape.go index da07cda..a9c5dc6 100644 --- a/internal/kernel/requirementsourcecodec/shape.go +++ b/internal/kernel/requirementsourcecodec/shape.go @@ -11,6 +11,8 @@ import ( type shapeKind uint8 +type collectionLimitID string + const ( shapeObject shapeKind = iota + 1 shapeArray @@ -19,6 +21,18 @@ const ( shapeBoolean ) +const ( + limitCollectionItems collectionLimitID = "max_collection_items" + limitDefinitions collectionLimitID = "max_definitions" + limitDerivations collectionLimitID = "max_derivations" + limitExamplesPerScenario collectionLimitID = "max_examples_per_scenario" + limitGroups collectionLimitID = "max_groups" + limitMembersPerGroup collectionLimitID = "max_members_per_group" + limitProfiles collectionLimitID = "max_profiles" + limitScenarios collectionLimitID = "max_scenarios" + limitTerms collectionLimitID = "max_terms" +) + type shapeField struct { shape *shape required bool @@ -31,6 +45,7 @@ type shape struct { element *shape nullable bool maxItems int + limitOwner collectionLimitID exactString string exactInt *int64 } @@ -39,7 +54,7 @@ func documentShape(limits requirementsourcemodel.Limits) *shape { stringValue := scalar(shapeString) booleanValue := scalar(shapeBoolean) integerValue := scalar(shapeInteger) - stringsValue := array(stringValue, limits.MaxCollectionItems) + stringsValue := array(stringValue, limitCollectionItems, limits) lifecycleValue := object(requiredFields(map[string]*shape{ "state": stringValue, "replacementRequirementIds": stringsValue, "evidenceRefs": stringsValue, })) @@ -67,14 +82,14 @@ func documentShape(limits requirementsourcemodel.Limits) *shape { memberValue := object(requiredFields(map[string]*shape{"requirementId": stringValue, "statementCompletion": stringValue, "fields": metadataValue})) groupValue := object(requiredFields(map[string]*shape{ "groupId": stringValue, "profileId": stringValue, "statementStem": stringValue, "sharedPremises": stringsValue, - "members": array(memberValue, limits.MaxMembersPerGroup), + "members": array(memberValue, limitMembersPerGroup, limits), })) - valuesValue := &shape{kind: shapeObject, dynamic: stringValue, maxItems: limits.MaxCollectionItems} + valuesValue := dynamicMap(stringValue, limitCollectionItems, limits) exampleValue := object(requiredFields(map[string]*shape{"exampleId": stringValue, "values": valuesValue})) scenarioValue := object(requiredFields(map[string]*shape{ "scenarioId": stringValue, "requirementIds": stringsValue, "parameters": stringsValue, "preconditions": stringsValue, "actionSequence": stringsValue, "expectedObservations": stringsValue, - "forbiddenObservations": stringsValue, "examples": array(exampleValue, limits.MaxExamplesPerScenario), + "forbiddenObservations": stringsValue, "examples": array(exampleValue, limitExamplesPerScenario, limits), "vocabularyRefs": stringsValue, "nonClaimRefs": stringsValue, })) version := int64(SchemaVersion) @@ -88,12 +103,12 @@ func documentShape(limits requirementsourcemodel.Limits) *shape { "sourceId": stringValue, "specPackagePath": stringValue, "sourceNonClaimRefs": stringsValue, - "nonClaimDefinitions": array(nonClaimValue, limits.MaxDefinitions), - "vocabulary": array(termValue, limits.MaxTerms), - "derivations": array(derivationValue, limits.MaxDerivations), - "profiles": array(profileValue, limits.MaxProfiles), - "groups": array(groupValue, limits.MaxGroups), - "scenarios": array(scenarioValue, limits.MaxScenarios), + "nonClaimDefinitions": array(nonClaimValue, limitDefinitions, limits), + "vocabulary": array(termValue, limitTerms, limits), + "derivations": array(derivationValue, limitDerivations, limits), + "profiles": array(profileValue, limitProfiles, limits), + "groups": array(groupValue, limitGroups, limits), + "scenarios": array(scenarioValue, limitScenarios, limits), })) } @@ -253,8 +268,37 @@ func object(fields map[string]shapeField) *shape { return &shape{kind: shapeObject, fields: fields} } -func array(element *shape, maxItems int) *shape { - return &shape{kind: shapeArray, element: element, maxItems: maxItems} +func array(element *shape, owner collectionLimitID, limits requirementsourcemodel.Limits) *shape { + return &shape{kind: shapeArray, element: element, maxItems: collectionLimit(owner, limits), limitOwner: owner} +} + +func dynamicMap(element *shape, owner collectionLimitID, limits requirementsourcemodel.Limits) *shape { + return &shape{kind: shapeObject, dynamic: element, maxItems: collectionLimit(owner, limits), limitOwner: owner} +} + +func collectionLimit(owner collectionLimitID, limits requirementsourcemodel.Limits) int { + switch owner { + case limitCollectionItems: + return limits.MaxCollectionItems + case limitDefinitions: + return limits.MaxDefinitions + case limitDerivations: + return limits.MaxDerivations + case limitExamplesPerScenario: + return limits.MaxExamplesPerScenario + case limitGroups: + return limits.MaxGroups + case limitMembersPerGroup: + return limits.MaxMembersPerGroup + case limitProfiles: + return limits.MaxProfiles + case limitScenarios: + return limits.MaxScenarios + case limitTerms: + return limits.MaxTerms + default: + panic("unknown collection limit owner") + } } func scalar(kind shapeKind) *shape { diff --git a/internal/kernel/requirementsourcecodec/testdata/codec-field-manifest.v1.json b/internal/kernel/requirementsourcecodec/testdata/codec-field-manifest.v1.json index 2daeef4..ba02a3e 100644 --- a/internal/kernel/requirementsourcecodec/testdata/codec-field-manifest.v1.json +++ b/internal/kernel/requirementsourcecodec/testdata/codec-field-manifest.v1.json @@ -18,7 +18,7 @@ {"name": "reviewCondition", "type": "string", "required": true, "nullable": false}, {"name": "expiryRef", "type": "string", "required": true, "nullable": false}, {"name": "mergePolicy", "type": "string", "required": true, "nullable": false}, - {"name": "evidenceRefs", "type": "array:string", "required": true, "nullable": false} + {"name": "evidenceRefs", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"} ] }, { @@ -28,8 +28,8 @@ {"name": "sourceKind", "type": "string", "required": true, "nullable": false}, {"name": "sourceRef", "type": "record:gitBlobRef", "required": true, "nullable": false}, {"name": "selector", "type": "record:byteRange", "required": true, "nullable": false}, - {"name": "requirementIds", "type": "array:string", "required": true, "nullable": false}, - {"name": "nonClaimRefs", "type": "array:string", "required": true, "nullable": false} + {"name": "requirementIds", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"}, + {"name": "nonClaimRefs", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"} ] }, { @@ -39,20 +39,20 @@ {"name": "kind", "type": "string", "required": true, "nullable": false, "constant": "proofkit.requirement-source"}, {"name": "sourceId", "type": "string", "required": true, "nullable": false}, {"name": "specPackagePath", "type": "string", "required": true, "nullable": false}, - {"name": "sourceNonClaimRefs", "type": "array:string", "required": true, "nullable": false}, - {"name": "nonClaimDefinitions", "type": "array:record:nonClaimDefinition", "required": true, "nullable": false}, - {"name": "vocabulary", "type": "array:record:vocabularyTerm", "required": true, "nullable": false}, - {"name": "derivations", "type": "array:record:derivation", "required": true, "nullable": false}, - {"name": "profiles", "type": "array:record:profile", "required": true, "nullable": false}, - {"name": "groups", "type": "array:record:group", "required": true, "nullable": false}, - {"name": "scenarios", "type": "array:record:scenario", "required": true, "nullable": false} + {"name": "sourceNonClaimRefs", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"}, + {"name": "nonClaimDefinitions", "type": "array:record:nonClaimDefinition", "required": true, "nullable": false, "limitOwner": "max_definitions"}, + {"name": "vocabulary", "type": "array:record:vocabularyTerm", "required": true, "nullable": false, "limitOwner": "max_terms"}, + {"name": "derivations", "type": "array:record:derivation", "required": true, "nullable": false, "limitOwner": "max_derivations"}, + {"name": "profiles", "type": "array:record:profile", "required": true, "nullable": false, "limitOwner": "max_profiles"}, + {"name": "groups", "type": "array:record:group", "required": true, "nullable": false, "limitOwner": "max_groups"}, + {"name": "scenarios", "type": "array:record:scenario", "required": true, "nullable": false, "limitOwner": "max_scenarios"} ] }, { "recordId": "example", "fields": [ {"name": "exampleId", "type": "string", "required": true, "nullable": false}, - {"name": "values", "type": "map:string", "required": true, "nullable": false} + {"name": "values", "type": "map:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"} ] }, { @@ -70,16 +70,16 @@ {"name": "groupId", "type": "string", "required": true, "nullable": false}, {"name": "profileId", "type": "string", "required": true, "nullable": false}, {"name": "statementStem", "type": "string", "required": true, "nullable": false}, - {"name": "sharedPremises", "type": "array:string", "required": true, "nullable": false}, - {"name": "members", "type": "array:record:member", "required": true, "nullable": false} + {"name": "sharedPremises", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"}, + {"name": "members", "type": "array:record:member", "required": true, "nullable": false, "limitOwner": "max_members_per_group"} ] }, { "recordId": "lifecycle", "fields": [ {"name": "state", "type": "string", "required": true, "nullable": false}, - {"name": "replacementRequirementIds", "type": "array:string", "required": true, "nullable": false}, - {"name": "evidenceRefs", "type": "array:string", "required": true, "nullable": false} + {"name": "replacementRequirementIds", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"}, + {"name": "evidenceRefs", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"} ] }, { @@ -96,7 +96,7 @@ {"name": "ownerId", "type": "string", "required": false, "nullable": false}, {"name": "claimLevel", "type": "string", "required": false, "nullable": false}, {"name": "riskClass", "type": "string", "required": false, "nullable": false}, - {"name": "nonClaimRefs", "type": "array:string", "required": false, "nullable": false}, + {"name": "nonClaimRefs", "type": "array:string", "required": false, "nullable": false, "limitOwner": "max_collection_items"}, {"name": "lifecycle", "type": "record:lifecycle", "required": false, "nullable": false}, {"name": "deferral", "type": "record:deferral", "required": false, "nullable": true}, {"name": "updatePolicy", "type": "record:updatePolicy", "required": false, "nullable": false} @@ -120,15 +120,15 @@ "recordId": "scenario", "fields": [ {"name": "scenarioId", "type": "string", "required": true, "nullable": false}, - {"name": "requirementIds", "type": "array:string", "required": true, "nullable": false}, - {"name": "parameters", "type": "array:string", "required": true, "nullable": false}, - {"name": "preconditions", "type": "array:string", "required": true, "nullable": false}, - {"name": "actionSequence", "type": "array:string", "required": true, "nullable": false}, - {"name": "expectedObservations", "type": "array:string", "required": true, "nullable": false}, - {"name": "forbiddenObservations", "type": "array:string", "required": true, "nullable": false}, - {"name": "examples", "type": "array:record:example", "required": true, "nullable": false}, - {"name": "vocabularyRefs", "type": "array:string", "required": true, "nullable": false}, - {"name": "nonClaimRefs", "type": "array:string", "required": true, "nullable": false} + {"name": "requirementIds", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"}, + {"name": "parameters", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"}, + {"name": "preconditions", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"}, + {"name": "actionSequence", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"}, + {"name": "expectedObservations", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"}, + {"name": "forbiddenObservations", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"}, + {"name": "examples", "type": "array:record:example", "required": true, "nullable": false, "limitOwner": "max_examples_per_scenario"}, + {"name": "vocabularyRefs", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"}, + {"name": "nonClaimRefs", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"} ] }, { diff --git a/internal/kernel/requirementsourcecodec/testdata/codec-selection.v1.json b/internal/kernel/requirementsourcecodec/testdata/codec-selection.v1.json index d2a7f19..0177cc0 100644 --- a/internal/kernel/requirementsourcecodec/testdata/codec-selection.v1.json +++ b/internal/kernel/requirementsourcecodec/testdata/codec-selection.v1.json @@ -2,17 +2,24 @@ "schemaVersion": 1, "kind": "proofkit.requirement-source-codec-selection", "screenEvidence": { - "screenManifestVersion": 3, - "screenManifestSha256": "80de3139937b864076254a7cca36820bdf54bef7c06e4aad1091d77910e643ea", - "selectionOpeningSha256": "72306508677a38683186e965cf6efc21aba080571d89b4d9c8cb4e65a2162347", - "fixtureIndexSha256": "505bb896082805708975bbc698e2414f625cb85bb1272512483c8c263efb54e0", - "observationsSha256": "0abd85c4dfaf3f8a3cdd1bad366e1961d5e57ed47a8bd14321a48f308ae66b7d", - "independentValidationSha256": "f565439bc2d8001a67f37cca5125bf6d1b70c4ba6cf1cb3f038b1fb1b2c670b2", - "reviewResultsSha256": "3e632343ef36f27f472ecf7560d2b75f7cad4a35c15678ce7eb078b277512b63", - "tokenProducerDigests": { - "openai-tiktoken-0.14.0": "e19d6985ad9599c86bbd672aef93dfed4e1643e48282e0e1ee91a388721e55db", - "tiktoken-go-0.8.1": "7c185ae59f0551c86fb2f30fa0ea338103baa278e267cafb0e43e6205f0fa4eb" - } + "archiveFormat": "tar+gzip", + "archivePath": "testdata/screen-v3.tgz", + "archiveSha256": "a65f99b14e0085daf6b9d4db6e9fbf0001aa4f6dd65e0e151c1680694e189435", + "rootPath": ".", + "treeDigestAlgorithm": "sha256(sorted(relative-path NUL file-sha256 LF))", + "treeSha256": "1dd5a2c6f9635ae81deeb497e077c6d49e59cbd9b0a94355e8ab5cf94d2192a2", + "artifactCount": 197, + "artifacts": [ + {"role": "fixture-index", "path": "out-v3/fixture-index.v1.json", "sha256": "505bb896082805708975bbc698e2414f625cb85bb1272512483c8c263efb54e0"}, + {"role": "independent-validation", "path": "out-v3/screen-validation.v3.json", "sha256": "f565439bc2d8001a67f37cca5125bf6d1b70c4ba6cf1cb3f038b1fb1b2c670b2"}, + {"role": "observations", "path": "out-v3/screen-observations.partial.v3.json", "sha256": "0abd85c4dfaf3f8a3cdd1bad366e1961d5e57ed47a8bd14321a48f308ae66b7d"}, + {"role": "review-results", "path": "out-v3/review-results.v3.json", "sha256": "3e632343ef36f27f472ecf7560d2b75f7cad4a35c15678ce7eb078b277512b63"}, + {"role": "screen-decision", "path": "out-v3/screen-decision.v3.json", "sha256": "bfa8b48b13efe10fc251c2792306e2c9f10e9258bf89362ddfe081c9460794b0"}, + {"role": "screen-manifest", "path": "screen-manifest.v3.json", "sha256": "80de3139937b864076254a7cca36820bdf54bef7c06e4aad1091d77910e643ea"}, + {"role": "selection-opening", "path": "out-v3/selection-opening.v3.json", "sha256": "72306508677a38683186e965cf6efc21aba080571d89b4d9c8cb4e65a2162347"}, + {"role": "token-go", "path": "out-v3/tokens-go.v3.json", "sha256": "7c185ae59f0551c86fb2f30fa0ea338103baa278e267cafb0e43e6205f0fa4eb"}, + {"role": "token-python", "path": "out-v3/tokens-python.v3.json", "sha256": "e19d6985ad9599c86bbd672aef93dfed4e1643e48282e0e1ee91a388721e55db"} + ] }, "roles": { "statusQuoComparators": ["flat-v1"], @@ -27,16 +34,20 @@ "restrictedTextChallengers": ["proofkit-source-text-v1"], "codecCandidates": ["grouped-json-v1"] }, + "jsonLayoutOrder": ["weighted_tokens_o200k_base", "weighted_canonical_bytes", "changed_bytes", "changed_lines", "candidate_id"], "metricRegistry": [ + {"metricId": "aggregate_diff_regression_basis_points", "stage": "replacement", "role": "hard", "direction": "minimize", "baseline": "selected-json-layout", "aggregation": "maximum-aggregate-diff-regression", "requirement": "at-most-policy-threshold", "missing": "reject", "materialThreshold": 500}, {"metricId": "changed_bytes", "stage": "screen", "role": "primary", "direction": "minimize", "baseline": "eligible-json-layouts", "aggregation": "sum-over-frozen-edits", "requirement": "lexicographic-minimum", "missing": "reject", "materialThreshold": 0}, {"metricId": "changed_lines", "stage": "screen", "role": "primary", "direction": "minimize", "baseline": "eligible-json-layouts", "aggregation": "sum-over-frozen-edits", "requirement": "lexicographic-minimum", "missing": "reject", "materialThreshold": 0}, {"metricId": "edit_locality", "stage": "screen", "role": "hard", "direction": "equal", "baseline": "affected-entity-registry", "aggregation": "all-frozen-edits", "requirement": "true", "missing": "fail", "materialThreshold": 0}, {"metricId": "field_closure", "stage": "screen", "role": "hard", "direction": "equal", "baseline": "codec-field-manifest-v1", "aggregation": "all-fields", "requirement": "passed", "missing": "fail", "materialThreshold": 0}, - {"metricId": "format_time", "stage": "replacement", "role": "primary", "direction": "minimize", "baseline": "selected-grouped-json-v1", "aggregation": "paired-randomized-confidence-bound", "requirement": "noninferior", "missing": "reject", "materialThreshold": 0}, + {"metricId": "format_time_state", "stage": "replacement", "role": "hard", "direction": "equal", "baseline": "selected-grouped-json-v1", "aggregation": "paired-randomized-confidence-bound", "requirement": "passed", "missing": "reject", "materialThreshold": 0}, {"metricId": "invalid_mutation_false_accepts", "stage": "screen", "role": "hard", "direction": "minimize", "baseline": "frozen-invalid-review-task", "aggregation": "sum", "requirement": "zero", "missing": "fail", "materialThreshold": 0}, - {"metricId": "parse_time", "stage": "replacement", "role": "primary", "direction": "minimize", "baseline": "selected-grouped-json-v1", "aggregation": "paired-randomized-confidence-bound", "requirement": "noninferior", "missing": "reject", "materialThreshold": 0}, - {"metricId": "projected_production_branches", "stage": "screen", "role": "report_only", "direction": "minimize", "baseline": "selected-json-layout", "aggregation": "estimate", "requirement": "none", "missing": "unknown", "materialThreshold": 0}, - {"metricId": "projected_production_loc", "stage": "screen", "role": "report_only", "direction": "minimize", "baseline": "selected-json-layout", "aggregation": "estimate", "requirement": "none", "missing": "unknown", "materialThreshold": 0}, + {"metricId": "lower_cost_dominance_state", "stage": "replacement", "role": "hard", "direction": "equal", "baseline": "eligible-lower-cost-comparators", "aggregation": "all-primary-metrics", "requirement": "passed", "missing": "reject", "materialThreshold": 0}, + {"metricId": "parse_time_state", "stage": "replacement", "role": "hard", "direction": "equal", "baseline": "selected-grouped-json-v1", "aggregation": "paired-randomized-confidence-bound", "requirement": "passed", "missing": "reject", "materialThreshold": 0}, + {"metricId": "per_edit_diff_regression_basis_points", "stage": "replacement", "role": "hard", "direction": "minimize", "baseline": "selected-json-layout", "aggregation": "maximum-per-edit-class-diff-regression", "requirement": "at-most-policy-threshold", "missing": "reject", "materialThreshold": 1500}, + {"metricId": "projected_production_branches", "stage": "replacement", "role": "hard", "direction": "minimize", "baseline": "selected-json-layout", "aggregation": "estimate", "requirement": "at-most-policy-ratio", "missing": "reject", "materialThreshold": 15000}, + {"metricId": "projected_production_loc", "stage": "replacement", "role": "hard", "direction": "minimize", "baseline": "selected-json-layout", "aggregation": "estimate", "requirement": "at-most-policy-ratio", "missing": "reject", "materialThreshold": 15000}, {"metricId": "review_accuracy_basis_points", "stage": "screen", "role": "hard", "direction": "maximize", "baseline": "maximum-observed-json-layout", "aggregation": "exact-gold-answers", "requirement": "equal-to-maximum", "missing": "fail", "materialThreshold": 0}, {"metricId": "weighted_canonical_bytes", "stage": "screen", "role": "primary", "direction": "minimize", "baseline": "eligible-json-layouts", "aggregation": "weighted-sum", "requirement": "lexicographic-minimum", "missing": "reject", "materialThreshold": 0}, {"metricId": "weighted_tokens_o200k_base", "stage": "screen", "role": "primary", "direction": "minimize", "baseline": "eligible-json-layouts", "aggregation": "weighted-sum", "requirement": "lexicographic-minimum", "missing": "reject", "materialThreshold": 0} @@ -51,12 +62,12 @@ "maximumProjectedProductionCostBasisPoints": 15000 }, "screenObservations": [ - {"candidateId": "json-compact-v1", "fieldClosure": "passed", "reviewAccuracyBasisPoints": 10000, "invalidMutationFalseAccepts": 0, "editLocality": false, "weightedCanonicalBytes": 2458590, "weightedTokensO200kBase": 652930, "changedLines": 16, "changedBytes": 57377, "projectedProductionLoc": 520, "projectedProductionBranches": 44, "aggregateDiffState": "not_applicable", "perEditDiffState": "not_applicable", "parseTimeState": "not_applicable", "formatTimeState": "not_applicable", "lowerCostDominanceState": "not_applicable"}, - {"candidateId": "json-hybrid-v1", "fieldClosure": "passed", "reviewAccuracyBasisPoints": 10000, "invalidMutationFalseAccepts": 0, "editLocality": true, "weightedCanonicalBytes": 2605965, "weightedTokensO200kBase": 698725, "changedLines": 34, "changedBytes": 5721, "projectedProductionLoc": 535, "projectedProductionBranches": 46, "aggregateDiffState": "not_applicable", "perEditDiffState": "not_applicable", "parseTimeState": "not_applicable", "formatTimeState": "not_applicable", "lowerCostDominanceState": "not_applicable"}, - {"candidateId": "json-pretty-v1", "fieldClosure": "passed", "reviewAccuracyBasisPoints": 10000, "invalidMutationFalseAccepts": 0, "editLocality": false, "weightedCanonicalBytes": 4477120, "weightedTokensO200kBase": 1018030, "changedLines": 200, "changedBytes": 6863, "projectedProductionLoc": 520, "projectedProductionBranches": 44, "aggregateDiffState": "not_applicable", "perEditDiffState": "not_applicable", "parseTimeState": "not_applicable", "formatTimeState": "not_applicable", "lowerCostDominanceState": "not_applicable"}, - {"candidateId": "proofkit-source-text-v1", "fieldClosure": "not_applicable", "reviewAccuracyBasisPoints": null, "invalidMutationFalseAccepts": null, "editLocality": false, "weightedCanonicalBytes": 3899810, "weightedTokensO200kBase": 769855, "changedLines": 166, "changedBytes": 6091, "projectedProductionLoc": 910, "projectedProductionBranches": 92, "aggregateDiffState": "failed", "perEditDiffState": "failed", "parseTimeState": "missing", "formatTimeState": "missing", "lowerCostDominanceState": "failed"}, - {"candidateId": "toml-tristate-v1", "fieldClosure": "not_applicable", "reviewAccuracyBasisPoints": null, "invalidMutationFalseAccepts": null, "editLocality": false, "weightedCanonicalBytes": 3167570, "weightedTokensO200kBase": 827095, "changedLines": 154, "changedBytes": 4949, "projectedProductionLoc": 720, "projectedProductionBranches": 65, "aggregateDiffState": "not_applicable", "perEditDiffState": "not_applicable", "parseTimeState": "not_applicable", "formatTimeState": "not_applicable", "lowerCostDominanceState": "not_applicable"}, - {"candidateId": "yaml-strict-v1", "fieldClosure": "not_applicable", "reviewAccuracyBasisPoints": null, "invalidMutationFalseAccepts": null, "editLocality": false, "weightedCanonicalBytes": 3397905, "weightedTokensO200kBase": 783225, "changedLines": 153, "changedBytes": 5685, "projectedProductionLoc": 690, "projectedProductionBranches": 61, "aggregateDiffState": "not_applicable", "perEditDiffState": "not_applicable", "parseTimeState": "not_applicable", "formatTimeState": "not_applicable", "lowerCostDominanceState": "not_applicable"} + {"candidateId": "json-compact-v1", "fieldClosure": "passed", "reviewAccuracyBasisPoints": 10000, "invalidMutationFalseAccepts": 0, "editLocality": false, "weightedCanonicalBytes": 2458590, "weightedTokensO200kBase": 652930, "changedLines": 16, "changedBytes": 57377, "projectedProductionLoc": 520, "projectedProductionBranches": 44, "aggregateDiffRegressionBasisPoints": null, "perEditDiffRegressionBasisPoints": null, "parseTimeState": "not_applicable", "formatTimeState": "not_applicable", "lowerCostDominanceState": "not_applicable"}, + {"candidateId": "json-hybrid-v1", "fieldClosure": "passed", "reviewAccuracyBasisPoints": 10000, "invalidMutationFalseAccepts": 0, "editLocality": true, "weightedCanonicalBytes": 2605965, "weightedTokensO200kBase": 698725, "changedLines": 34, "changedBytes": 5721, "projectedProductionLoc": 535, "projectedProductionBranches": 46, "aggregateDiffRegressionBasisPoints": null, "perEditDiffRegressionBasisPoints": null, "parseTimeState": "not_applicable", "formatTimeState": "not_applicable", "lowerCostDominanceState": "not_applicable"}, + {"candidateId": "json-pretty-v1", "fieldClosure": "passed", "reviewAccuracyBasisPoints": 10000, "invalidMutationFalseAccepts": 0, "editLocality": false, "weightedCanonicalBytes": 4477120, "weightedTokensO200kBase": 1018030, "changedLines": 200, "changedBytes": 6863, "projectedProductionLoc": 520, "projectedProductionBranches": 44, "aggregateDiffRegressionBasisPoints": null, "perEditDiffRegressionBasisPoints": null, "parseTimeState": "not_applicable", "formatTimeState": "not_applicable", "lowerCostDominanceState": "not_applicable"}, + {"candidateId": "proofkit-source-text-v1", "fieldClosure": "not_applicable", "reviewAccuracyBasisPoints": null, "invalidMutationFalseAccepts": null, "editLocality": false, "weightedCanonicalBytes": 3899810, "weightedTokensO200kBase": 769855, "changedLines": 166, "changedBytes": 6091, "projectedProductionLoc": 910, "projectedProductionBranches": 92, "aggregateDiffRegressionBasisPoints": 38823, "perEditDiffRegressionBasisPoints": 120000, "parseTimeState": "missing", "formatTimeState": "missing", "lowerCostDominanceState": "failed"}, + {"candidateId": "toml-tristate-v1", "fieldClosure": "not_applicable", "reviewAccuracyBasisPoints": null, "invalidMutationFalseAccepts": null, "editLocality": false, "weightedCanonicalBytes": 3167570, "weightedTokensO200kBase": 827095, "changedLines": 154, "changedBytes": 4949, "projectedProductionLoc": 720, "projectedProductionBranches": 65, "aggregateDiffRegressionBasisPoints": null, "perEditDiffRegressionBasisPoints": null, "parseTimeState": "not_applicable", "formatTimeState": "not_applicable", "lowerCostDominanceState": "not_applicable"}, + {"candidateId": "yaml-strict-v1", "fieldClosure": "not_applicable", "reviewAccuracyBasisPoints": null, "invalidMutationFalseAccepts": null, "editLocality": false, "weightedCanonicalBytes": 3397905, "weightedTokensO200kBase": 783225, "changedLines": 153, "changedBytes": 5685, "projectedProductionLoc": 690, "projectedProductionBranches": 61, "aggregateDiffRegressionBasisPoints": null, "perEditDiffRegressionBasisPoints": null, "parseTimeState": "not_applicable", "formatTimeState": "not_applicable", "lowerCostDominanceState": "not_applicable"} ], "decision": { "state": "grouped_json_only", @@ -66,14 +77,45 @@ "productionGrammarCount": 1 }, "hardGateSelectors": [ + "TestSelectionRecordIsClosedAndDecisionIsReproducible", + "TestSelectionEvidenceIsByteBoundAndProjectsDecision", + "TestScreenTreeDigestRejectsByteAndInventoryMutation", + "TestScreenArchiveAdmissionRejectsUnsafeTopology", + "TestChallengerEligibilityRequiresEveryReplacementPredicate", + "TestProductionPackageHasOneCodecAndNoSyntaxDependency", + "TestRepositoryHasOneV2GrammarIdentityOwner", "TestFieldManifestMatchesWireDTOAndClosedShape", "TestFormatParseRoundTripPreservesEveryProjection", "TestCanonicalFormatIsIdempotent", - "TestChallengerEligibilityRequiresEveryReplacementPredicate", + "TestFormatPreservesMetadataAbsenceNullAndRecord", + "TestSourceMapReplaysKeyAndValueSpans", + "TestReturnedSourceMapIsImmutable", + "TestSourceMapIndexesLexicalWireOrderNotNormalizedOrder", + "TestHybridLayoutKeepsStableSiblingEntityLinesUnchanged", + "TestHybridLayoutUsesCommaFirstEntityArrays", + "TestCanonicalStringsEscapeUnsafeScalarsWithoutHTMLEscaping", + "TestCanonicalMapKeysAreSorted", + "TestOrderedActionsRetainOrder", "TestCodecMutantManifestClosesRepresentationFailures", - "TestParseDiagnosticsDoNotDiscloseCallerTextOrDynamicKeys", "TestRawByteBoundaryIsExactAndDominatesUTF8", - "TestHybridLayoutKeepsStableSiblingEntityLinesUnchanged" + "TestTokenAndNestingLimitsPrecedeShapeAdmission", + "TestLexicalTokenLimitDominatesNestingWhenBothFail", + "TestRepresentationCollectionLimitPrecedesModelSemantics", + "TestDynamicMapCollectionLimitPrecedesParameterSemantics", + "TestModelResourcePreflightPrecedesSemanticValidation", + "TestCodecLimitsCannotUnderCoverModel", + "TestLimitCoefficientManifestMatchesProductionFormula", + "TestLimitArithmeticRejectsOverflow", + "TestCanonicalByteBoundCoversWorstAdmittedEscapeExpansion", + "TestInvalidUTF8UsesByteOnlyCoordinates", + "TestValidUnicodeDiagnosticsUseScalarColumns", + "TestBareCRAndCRLFAdvanceScalarLinesOnce", + "TestMultipleValueDiagnosticSpansSecondToken", + "TestParseDiagnosticsDoNotDiscloseCallerTextOrDynamicKeys", + "TestPreShapeDiagnosticsDoNotDiscloseUnknownKeys", + "TestParseDiagnosticsRedactSemanticEntityIDsAndResolveExactSpan", + "TestParseAcceptsValidUnicodeSurrogatePairLosslessly", + "TestFuzzSeedCorpusRoundTrips" ], "nonClaims": [ "The screen does not prove corpus-external review or token outcomes.", diff --git a/internal/kernel/requirementsourcecodec/testdata/screen-v3.tgz b/internal/kernel/requirementsourcecodec/testdata/screen-v3.tgz new file mode 100644 index 0000000000000000000000000000000000000000..5142f16020a6b3a20135f3de65d9d18be52f3231 GIT binary patch literal 99210 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 literal 0 HcmV?d00001 diff --git a/internal/kernel/requirementsourcecodec/types.go b/internal/kernel/requirementsourcecodec/types.go index 9886cd4..e1159e0 100644 --- a/internal/kernel/requirementsourcecodec/types.go +++ b/internal/kernel/requirementsourcecodec/types.go @@ -39,6 +39,8 @@ type SourceMap struct { entries map[string]Location } +// Location resolves a lexical JSON pointer in the admitted wire source. Array +// indexes describe the caller's source order, not normalized model order. func (sourceMap SourceMap) Location(pointer string) (Location, bool) { location, exists := sourceMap.entries[pointer] return cloneLocation(location), exists diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go index 590a129..b855e64 100644 --- a/internal/tools/releasechange/record_test.go +++ b/internal/tools/releasechange/record_test.go @@ -8,8 +8,6 @@ import ( "slices" "strings" "testing" - - "github.com/research-engineering/agentic-proofkit/internal/command/jsonreportcliadaptersource" ) func TestAdmitAndRenderVersionBoundChangeRecord(t *testing.T) { @@ -196,23 +194,13 @@ func TestCurrentChangeRecordNamesReviewedSemanticChanges(t *testing.T) { assertCurrentChangeRecordNotesRejected(t, "appended duplicate change section", record, notes+"## Breaking Contract Changes\n\n- `proofkit.surplus.section`: Surplus section.\n") } -var currentBreakingChanges = []Change{ - {ChangeID: "proofkit.agent-envelope.local-identity-closure", Summary: "Agent envelopes now require unique, pairwise-disjoint command, context, and receipt identities and fail closed by pruning unsafe or ambiguous local targets and references with bounded omissions."}, - {ChangeID: "proofkit.diagnostic.bounded-error-boundary", Summary: "CLI and repository-tool diagnostics now reject malformed or report-unsafe values as a whole, cap admitted text, capture failed child stderr before whole-value sanitization, and keep successful child stderr out of diagnostics; installed-carrier smoke additionally bounds stdout, stderr, lifetime, and process-group cleanup."}, - {ChangeID: "proofkit.stable-json.unicode-scalar-v2", Summary: "Go, JavaScript, and generated TypeScript stable JSON now reject non-scalar strings and deterministically escape the pinned Unicode 15 control and format policy; the generated TypeScript adapter identity advances to v2."}, -} +var currentBreakingChanges = []Change{} var currentAdditions = []Change{ - {ChangeID: "proofkit.agent-workflow.change-planner", Summary: "Add a bounded change-workflow-plan command for the named optional proofkit.reviewed-change.v1 profile with canonical JSON, derived authority- and witness-gated agent-envelope, plain-text, and capability-gated terminal-color projections."}, - {ChangeID: "proofkit.agent-workflow.native-evidence-guidance", Summary: "Add a no-input native-evidence-guidance command that exposes the repository-owned decision template and closed applicability classes as canonical JSON or plain text without executing or approving consumer witnesses."}, - {ChangeID: "proofkit.release.cross-carrier-binary-identity", Summary: "Release assembly now admits final npm, Python, and SBOM artifacts once into a bounded immutable snapshot, independently rejects duplicate archive entries, directly compares embedded platform binaries in bounded chunks, and derives release and SBOM checksums from the same artifact epoch."}, + {ChangeID: "proofkit.requirement-source.codec-selection", Summary: "Select the private bounded grouped-JSON requirement-source v2 codec from byte-bound V3 screen evidence, with exact collection-limit ownership, nondisclosing source diagnostics, lexical source maps, decision-metric closure, and owner-scoped grammar identity proof."}, } -var currentMigrationSteps = []string{ - "Ensure every agent-envelope command, context, and receipt ID is individually safe and unique, keep the three local identity domains pairwise disjoint, and remove or rename references to ambiguous local targets before adopting 0.5.0.", - "Treat stderr as a bounded diagnostic class rather than an exact echo of caller or child-process text, and remove consumers that depend on raw forwarded diagnostics.", - "Ensure every stable-JSON string is a Unicode scalar sequence, accept deterministic escaping of pinned Unicode 15 control and format code points, and regenerate TypeScript adapters against generator " + jsonreportcliadaptersource.TypeScriptGeneratorID + ".", -} +var currentMigrationSteps = []string{} func validateCurrentChangeRecord(record Record, notes string) error { if !slices.Equal(record.BreakingChanges, currentBreakingChanges) { @@ -232,10 +220,11 @@ func validateCurrentChangeRecord(record Record, notes string) error { func currentExpectedReleaseNotes() string { lines := []string{ - "# @research-engineering/agentic-proofkit 0.5.0", + "# @research-engineering/agentic-proofkit 0.5.1", "", "## Breaking Contract Changes", "", + "- None.", } for _, change := range currentBreakingChanges { lines = append(lines, currentChangeBullet(change)) @@ -251,12 +240,8 @@ func currentExpectedReleaseNotes() string { "", "## Migration", "", - "Migration is required:", - "", + "No consumer migration is required.", ) - for _, step := range currentMigrationSteps { - lines = append(lines, "- "+step) - } lines = append(lines, "", "## Platform Requirements", @@ -267,7 +252,7 @@ func currentExpectedReleaseNotes() string { "", "- Agent workflow plans, prompts, text, and envelopes are derived guidance and do not execute agents, repository mutations, native witnesses, CI, release, rollout, or production operations.", "- Complete nested public structural contracts remain blocked under SCHEMA-01; current CLI contracts own exact root variants only.", - "- The requirement-source v2 codec remains unselected until SOURCE-CODEC-01 passes; the typed model is internal and no source cutover is claimed.", + "- The selected requirement-source v2 codec remains internal; current requirement sources are not migrated and no source cutover is claimed.", "- TSX source parsing remains unsupported.", "", "## Install", @@ -275,7 +260,7 @@ func currentExpectedReleaseNotes() string { "Primary npm channel:", "", "```bash", - "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.5.0", + "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.5.1", "```", "", "Pre-1.0 npm consumers must keep this dependency exact-pinned.", @@ -286,7 +271,7 @@ func currentExpectedReleaseNotes() string { "", "## Rollback", "", - "- Pin npm consumers to the previous admitted version 0.4.0 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.4.0`.", + "- Pin npm consumers to the previous admitted version 0.5.0 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.5.0`.", "- Treat local package artifacts as candidates until registry identity is proven.", ) return strings.Join(lines, "\n") + "\n" diff --git a/package-lock.json b/package-lock.json index cf3da45..a1cd681 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@research-engineering/agentic-proofkit", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@research-engineering/agentic-proofkit", - "version": "0.5.0", + "version": "0.5.1", "cpu": [ "arm64", "x64" diff --git a/package.json b/package.json index 015fe97..de40bd5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@research-engineering/agentic-proofkit", "description": "Reusable proof profile, report, graph, and witness-planning primitives.", - "version": "0.5.0", + "version": "0.5.1", "type": "module", "license": "MIT", "sideEffects": false, diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index a0b94d8..3e9e906 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -702,7 +702,7 @@ "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", "claimLevel": "blocking", "proofState": "witness_backed", - "nonClaims": ["This private codec does not migrate or rewrite current requirement sources, expose a public source extension or CLI command, retain a normalized mirror, authenticate requirement meaning or derivation provenance, generalize the frozen formatter screen beyond its corpus, execute native witnesses, approve merge or release, or establish rollout or production readiness."] + "nonClaims": ["This private codec does not migrate or rewrite current requirement sources, expose a public source extension or CLI command, retain a normalized mirror, authenticate requirement meaning or derivation provenance, generalize the frozen formatter screen beyond its byte-bound corpus, prevent a future owner-approved grammar from being added, execute native witnesses, approve merge or release, or establish rollout or production readiness."] }, { "requirementId": "REQ-PROOFKIT-WORKFLOW-001", @@ -4600,6 +4600,29 @@ "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "scenarioId": "proofkit.spec-proof-core.requirement-source-codec-selection-evidence-closure", + "witnessId": "proofkit.requirement-source-codec.selection-evidence-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcecodec/selection_evidence_test.go", + "witnessSelectors": [ + { + "selector": "TestSelectionEvidenceIsByteBoundAndProjectsDecision", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestSelectionEvidenceIsByteBoundAndProjectsDecision$'" + }, + { + "selector": "TestScreenTreeDigestRejectsByteAndInventoryMutation", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestScreenTreeDigestRejectsByteAndInventoryMutation$'" + }, + { + "selector": "TestScreenArchiveAdmissionRejectsUnsafeTopology", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestScreenArchiveAdmissionRejectsUnsafeTopology$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, { "requirementId": "REQ-PROOFKIT-SPEC-025", "scenarioId": "proofkit.spec-proof-core.requirement-source-codec-replacement-predicate-closure", @@ -4625,6 +4648,10 @@ { "selector": "TestProductionPackageHasOneCodecAndNoSyntaxDependency", "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestProductionPackageHasOneCodecAndNoSyntaxDependency$'" + }, + { + "selector": "TestRepositoryHasOneV2GrammarIdentityOwner", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestRepositoryHasOneV2GrammarIdentityOwner$'" } ], "commandIds": ["proofkit.go-test"], @@ -4671,6 +4698,10 @@ { "selector": "TestReturnedSourceMapIsImmutable", "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestReturnedSourceMapIsImmutable$'" + }, + { + "selector": "TestSourceMapIndexesLexicalWireOrderNotNormalizedOrder", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestSourceMapIndexesLexicalWireOrderNotNormalizedOrder$'" } ], "commandIds": ["proofkit.go-test"], @@ -4687,9 +4718,21 @@ "selector": "TestHybridLayoutKeepsStableSiblingEntityLinesUnchanged", "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestHybridLayoutKeepsStableSiblingEntityLinesUnchanged$'" }, + { + "selector": "TestHybridLayoutUsesCommaFirstEntityArrays", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestHybridLayoutUsesCommaFirstEntityArrays$'" + }, { "selector": "TestCanonicalStringsEscapeUnsafeScalarsWithoutHTMLEscaping", "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestCanonicalStringsEscapeUnsafeScalarsWithoutHTMLEscaping$'" + }, + { + "selector": "TestCanonicalMapKeysAreSorted", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestCanonicalMapKeysAreSorted$'" + }, + { + "selector": "TestOrderedActionsRetainOrder", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestOrderedActionsRetainOrder$'" } ], "commandIds": ["proofkit.go-test"], @@ -4721,9 +4764,29 @@ "selector": "TestRawByteBoundaryIsExactAndDominatesUTF8", "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestRawByteBoundaryIsExactAndDominatesUTF8$'" }, + { + "selector": "TestTokenAndNestingLimitsPrecedeShapeAdmission", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestTokenAndNestingLimitsPrecedeShapeAdmission$'" + }, + { + "selector": "TestLexicalTokenLimitDominatesNestingWhenBothFail", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestLexicalTokenLimitDominatesNestingWhenBothFail$'" + }, + { + "selector": "TestRepresentationCollectionLimitPrecedesModelSemantics", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestRepresentationCollectionLimitPrecedesModelSemantics$'" + }, { "selector": "TestDynamicMapCollectionLimitPrecedesParameterSemantics", "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestDynamicMapCollectionLimitPrecedesParameterSemantics$'" + }, + { + "selector": "TestModelResourcePreflightPrecedesSemanticValidation", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestModelResourcePreflightPrecedesSemanticValidation$'" + }, + { + "selector": "TestCodecLimitsCannotUnderCoverModel", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestCodecLimitsCannotUnderCoverModel$'" } ], "commandIds": ["proofkit.go-test"], @@ -4766,6 +4829,14 @@ { "selector": "TestValidUnicodeDiagnosticsUseScalarColumns", "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestValidUnicodeDiagnosticsUseScalarColumns$'" + }, + { + "selector": "TestBareCRAndCRLFAdvanceScalarLinesOnce", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestBareCRAndCRLFAdvanceScalarLinesOnce$'" + }, + { + "selector": "TestMultipleValueDiagnosticSpansSecondToken", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestMultipleValueDiagnosticSpansSecondToken$'" } ], "commandIds": ["proofkit.go-test"], @@ -4782,6 +4853,14 @@ "selector": "TestParseDiagnosticsDoNotDiscloseCallerTextOrDynamicKeys", "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestParseDiagnosticsDoNotDiscloseCallerTextOrDynamicKeys$'" }, + { + "selector": "TestPreShapeDiagnosticsDoNotDiscloseUnknownKeys", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestPreShapeDiagnosticsDoNotDiscloseUnknownKeys$'" + }, + { + "selector": "TestParseDiagnosticsRedactSemanticEntityIDsAndResolveExactSpan", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestParseDiagnosticsRedactSemanticEntityIDsAndResolveExactSpan$'" + }, { "selector": "TestParseAcceptsValidUnicodeSurrogatePairLosslessly", "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestParseAcceptsValidUnicodeSurrogatePairLosslessly$'" diff --git a/release/change-record.v2.json b/release/change-record.v2.json index c36e79b..959e934 100644 --- a/release/change-record.v2.json +++ b/release/change-record.v2.json @@ -1,43 +1,18 @@ { "schemaVersion": 2, - "previousVersion": "0.4.0", - "version": "0.5.0", - "changeClass": "breaking", - "breakingChanges": [ - { - "changeId": "proofkit.agent-envelope.local-identity-closure", - "summary": "Agent envelopes now require unique, pairwise-disjoint command, context, and receipt identities and fail closed by pruning unsafe or ambiguous local targets and references with bounded omissions." - }, - { - "changeId": "proofkit.diagnostic.bounded-error-boundary", - "summary": "CLI and repository-tool diagnostics now reject malformed or report-unsafe values as a whole, cap admitted text, capture failed child stderr before whole-value sanitization, and keep successful child stderr out of diagnostics; installed-carrier smoke additionally bounds stdout, stderr, lifetime, and process-group cleanup." - }, - { - "changeId": "proofkit.stable-json.unicode-scalar-v2", - "summary": "Go, JavaScript, and generated TypeScript stable JSON now reject non-scalar strings and deterministically escape the pinned Unicode 15 control and format policy; the generated TypeScript adapter identity advances to v2." - } - ], + "previousVersion": "0.5.0", + "version": "0.5.1", + "changeClass": "compatible", + "breakingChanges": [], "additions": [ { - "changeId": "proofkit.agent-workflow.change-planner", - "summary": "Add a bounded change-workflow-plan command for the named optional proofkit.reviewed-change.v1 profile with canonical JSON, derived authority- and witness-gated agent-envelope, plain-text, and capability-gated terminal-color projections." - }, - { - "changeId": "proofkit.agent-workflow.native-evidence-guidance", - "summary": "Add a no-input native-evidence-guidance command that exposes the repository-owned decision template and closed applicability classes as canonical JSON or plain text without executing or approving consumer witnesses." - }, - { - "changeId": "proofkit.release.cross-carrier-binary-identity", - "summary": "Release assembly now admits final npm, Python, and SBOM artifacts once into a bounded immutable snapshot, independently rejects duplicate archive entries, directly compares embedded platform binaries in bounded chunks, and derives release and SBOM checksums from the same artifact epoch." + "changeId": "proofkit.requirement-source.codec-selection", + "summary": "Select the private bounded grouped-JSON requirement-source v2 codec from byte-bound V3 screen evidence, with exact collection-limit ownership, nondisclosing source diagnostics, lexical source maps, decision-metric closure, and owner-scoped grammar identity proof." } ], "migration": { - "required": true, - "steps": [ - "Ensure every agent-envelope command, context, and receipt ID is individually safe and unique, keep the three local identity domains pairwise disjoint, and remove or rename references to ambiguous local targets before adopting 0.5.0.", - "Treat stderr as a bounded diagnostic class rather than an exact echo of caller or child-process text, and remove consumers that depend on raw forwarded diagnostics.", - "Ensure every stable-JSON string is a Unicode scalar sequence, accept deterministic escaping of pinned Unicode 15 control and format code points, and regenerate TypeScript adapters against generator proofkit.json-report-cli-adapter-source.typescript.v2." - ] + "required": false, + "steps": [] }, "platformRequirements": [ "Published Darwin package binaries require macOS 12.0 or later on arm64 and x86_64." @@ -45,7 +20,7 @@ "knownLimitations": [ "Agent workflow plans, prompts, text, and envelopes are derived guidance and do not execute agents, repository mutations, native witnesses, CI, release, rollout, or production operations.", "Complete nested public structural contracts remain blocked under SCHEMA-01; current CLI contracts own exact root variants only.", - "The requirement-source v2 codec remains unselected until SOURCE-CODEC-01 passes; the typed model is internal and no source cutover is claimed.", + "The selected requirement-source v2 codec remains internal; current requirement sources are not migrated and no source cutover is claimed.", "TSX source parsing remains unsupported." ], "rollback": { From c19346117e4e9e903e8155577e17b87202c92551 Mon Sep 17 00:00:00 2001 From: iperev Date: Tue, 1 Sep 2026 21:50:26 +0200 Subject: [PATCH 3/5] fix: close codec selection evidence gaps --- .../proofkit-spec-proof-core/overview.md | 15 +- .../requirements.v1.json | 4 +- .../package_boundary_test.go | 61 +-- .../selection_evaluator_test.go | 223 ++++++++-- .../selection_evidence_test.go | 133 +++--- .../selection_projection_test.go | 400 ++++++++++++++++++ .../requirementsourcecodec/selection_test.go | 44 +- .../testdata/codec-selection.v1.json | 12 +- proofkit/requirement-bindings.json | 29 +- 9 files changed, 734 insertions(+), 187 deletions(-) create mode 100644 internal/kernel/requirementsourcecodec/selection_projection_test.go diff --git a/docs/specs/proofkit-spec-proof-core/overview.md b/docs/specs/proofkit-spec-proof-core/overview.md index e7963ba..8793330 100644 --- a/docs/specs/proofkit-spec-proof-core/overview.md +++ b/docs/specs/proofkit-spec-proof-core/overview.md @@ -155,16 +155,19 @@ execution receipts, and merge policy. coverage without attributing correlated edits to independent field causality, selecting a codec, or changing a public source boundary. - `REQ-PROOFKIT-SPEC-025`: a versioned disjoint-role experiment selects one - private v2 source grammar, grouped JSON with an entity-local hybrid layout; + admitted private v2 source-grammar owner record, grouped JSON with an + entity-local hybrid layout; its strict bounded codec delegates meaning to the representation-neutral model, binds each collection to one model-limit owner, preserves every projection, metadata-presence state, and lexical source location, and emits - deterministic nondisclosing diagnostics. A bounded traversal-, symlink-, and - duplicate-closed archive byte-binds the exact V3 screen, while independent + deterministic nondisclosing diagnostics. A bounded single-member traversal-, + symlink-, duplicate-, and trailing-data-closed archive byte-binds the exact + V3 screen, while independent field, limit, selection, mutant, round-trip, fuzz-seed, package, and - repository identity witnesses close the selected owner boundary without - migrating current sources, exposing a new public CLI, or claiming that no - future owner-approved grammar can be added. + admitted grammar-owner-record and package-inventory witnesses close the + selected owner boundary without migrating current sources, exposing a new + public CLI, proving open-world absence of undeclared equivalent parsers, or + claiming that no future owner-approved grammar can be added. ## Non-Claims diff --git a/docs/specs/proofkit-spec-proof-core/requirements.v1.json b/docs/specs/proofkit-spec-proof-core/requirements.v1.json index 5555886..1f4f566 100644 --- a/docs/specs/proofkit-spec-proof-core/requirements.v1.json +++ b/docs/specs/proofkit-spec-proof-core/requirements.v1.json @@ -572,12 +572,12 @@ { "requirementId": "REQ-PROOFKIT-SPEC-025", "ownerId": "proofkit.spec-proof-core", - "invariant": "A private requirement-source codec selection admits grouped JSON with the frozen entity-local hybrid layout as the sole v2 persisted grammar owned by internal/kernel/requirementsourcecodec after a versioned disjoint-role screen rejects compact and pretty JSON layouts for edit-locality failure and admits no YAML, TOML, or restricted-text parser authority. The selection record byte-binds a bounded traversal-, symlink-, and duplicate-closed binary archive and the exact extracted V3 evidence tree, including its method sources, fixture corpus, rendered candidates, independent token reports, review results, validation, and decision, and its metric registry, layout order, thresholds, observations, and evaluator have exact decision closure. The selected codec maps bounded UTF-8 bytes through duplicate-, case-, unknown-, missing-, null-, integer-, and Unicode-scalar-closed structural admission into exactly one requirementsourcemodel.NormalizeWithLimits call; each wire collection names one model-limit owner; immutable atomic, authoring-layout, typed-reference, and lexical source-map projections preserve metadata absence, present-null deferral, present-record deferral, ordered actions, sorted dynamic maps, and caller wire order; and formatting re-admits the semantic model before emitting fixed-order entity-local canonical JSON with exact unsafe-scalar escaping and one final line feed. Raw-byte, UTF-8, lexical-token, nesting, representation-cardinality, model-resource, model-semantic, and canonical-output failures have fixed precedence, while diagnostic paths contain only canonical field names, numeric indexes, or placeholders and resolve synthetic model identities to exact wire spans. Independently authored field, limit-coefficient, selection, and executable mutant manifests close DTO fields and cardinalities, structural schema, resource formula, candidate roles, decision, diagnostic paths, and losing-grammar dependencies; round-trip, idempotence, source-span replay, exact-limit, fuzz-seed, exact package inventory, and repository-wide unique proofkit.requirement-source identity witnesses close the currently selected owner boundary without claiming an open-world ban on future grammars.", + "invariant": "A private requirement-source codec selection admits grouped JSON with the frozen entity-local hybrid layout through one admitted v2 persisted-grammar owner record for internal/kernel/requirementsourcecodec after a versioned disjoint-role screen rejects compact and pretty JSON layouts for edit-locality failure and admits no YAML, TOML, or restricted-text parser authority. The selection record byte-binds a bounded single-member traversal-, symlink-, duplicate-, and trailing-data-closed binary archive and the exact extracted V3 evidence tree, including its method sources, fixture corpus, rendered candidates, independent token reports, review results, validation, edit rows, and decision, and its metric registry, layout order, thresholds, observations, and evaluator have exact decision closure. The selected codec maps bounded UTF-8 bytes through duplicate-, case-, unknown-, missing-, null-, integer-, and Unicode-scalar-closed structural admission into exactly one requirementsourcemodel.NormalizeWithLimits call; each wire collection names one model-limit owner; immutable atomic, authoring-layout, typed-reference, and lexical source-map projections preserve metadata absence, present-null deferral, present-record deferral, ordered actions, sorted dynamic maps, and caller wire order; and formatting re-admits the semantic model before emitting fixed-order entity-local canonical JSON with exact unsafe-scalar escaping and one final line feed. Raw-byte, UTF-8, lexical-token, nesting, representation-cardinality, model-resource, model-semantic, and canonical-output failures have fixed precedence, while diagnostic paths contain only canonical field names, numeric indexes, or placeholders and resolve synthetic model identities to exact wire spans. Independently authored field, limit-coefficient, selection, and executable mutant manifests close DTO fields and cardinalities, structural schema, resource formula, candidate roles, decision, diagnostic paths, and losing-grammar dependencies; round-trip, idempotence, source-span replay, exact-limit, fuzz-seed, admitted grammar-owner record, and exact package-inventory witnesses close the currently selected owner boundary without claiming open-world absence of undeclared semantically equivalent parsers or a ban on future owner-approved grammars.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": ["proofkit/requirement-bindings.json"], "nonClaimRefs": ["NC-PROOFKIT-SPEC-025"], - "nonClaims": ["This private codec does not migrate or rewrite current requirement sources, expose a public source extension or CLI command, retain a normalized mirror, authenticate requirement meaning or derivation provenance, generalize the frozen formatter screen beyond its byte-bound corpus, prevent a future owner-approved grammar from being added, execute native witnesses, approve merge or release, or establish rollout or production readiness."], + "nonClaims": ["This private codec does not migrate or rewrite current requirement sources, expose a public source extension or CLI command, retain a normalized mirror, authenticate requirement meaning or derivation provenance, generalize the frozen formatter screen beyond its byte-bound corpus, prove the open-world absence of undeclared semantically equivalent parsers, prevent a future owner-approved grammar from being added, execute native witnesses, approve merge or release, or establish rollout or production readiness."], "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, "deferral": null, "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} diff --git a/internal/kernel/requirementsourcecodec/package_boundary_test.go b/internal/kernel/requirementsourcecodec/package_boundary_test.go index 58d0df8..48e5431 100644 --- a/internal/kernel/requirementsourcecodec/package_boundary_test.go +++ b/internal/kernel/requirementsourcecodec/package_boundary_test.go @@ -4,7 +4,6 @@ import ( "go/ast" "go/parser" "go/token" - "os" "path/filepath" "reflect" "sort" @@ -14,6 +13,7 @@ import ( ) func TestProductionPackageHasOneCodecAndNoSyntaxDependency(t *testing.T) { + record := readCodecSelection(t) allowedOwners := map[string]struct{}{ "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel": {}, "github.com/research-engineering/agentic-proofkit/internal/kernel/unicodepolicy": {}, @@ -72,7 +72,7 @@ func TestProductionPackageHasOneCodecAndNoSyntaxDependency(t *testing.T) { } } } - wantFiles := []string{"diagnostic_path.go", "document.go", "format.go", "json_index.go", "limits.go", "parse.go", "shape.go", "types.go"} + wantFiles := record.GrammarOwner.ProductionFiles if !reflect.DeepEqual(productionFiles, wantFiles) { t.Fatalf("production codec files = %v, want exact single-grammar surface %v", productionFiles, wantFiles) } @@ -89,45 +89,28 @@ func TestProductionPackageHasOneCodecAndNoSyntaxDependency(t *testing.T) { } } -func TestRepositoryHasOneV2GrammarIdentityOwner(t *testing.T) { - repositoryRoot := filepath.Clean(filepath.Join("..", "..", "..")) - owners := []string{} - err := filepath.WalkDir(repositoryRoot, func(pathValue string, entry os.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() { - switch entry.Name() { - case ".git", "artifacts", "dist", "node_modules", "testdata": - return filepath.SkipDir - } - return nil - } - if filepath.Ext(pathValue) != ".go" || strings.HasSuffix(pathValue, "_test.go") { - return nil - } - parsed, err := parser.ParseFile(token.NewFileSet(), pathValue, nil, 0) - if err != nil { - return err - } - ast.Inspect(parsed, func(node ast.Node) bool { - literal, ok := node.(*ast.BasicLit) - if !ok || literal.Kind != token.STRING { - return true - } - value, err := strconv.Unquote(literal.Value) - if err == nil && value == DocumentKind { - owners = append(owners, strings.TrimPrefix(pathValue, repositoryRoot+string(filepath.Separator))) - } - return true - }) - return nil - }) +func TestSelectedV2GrammarOwnerRecordIsExact(t *testing.T) { + owner := readCodecSelection(t).GrammarOwner + wantFiles := []string{"diagnostic_path.go", "document.go", "format.go", "json_index.go", "limits.go", "parse.go", "shape.go", "types.go"} + if owner.OwnerPackage != "internal/kernel/requirementsourcecodec" || owner.DocumentKind != DocumentKind || owner.SchemaVersion != SchemaVersion || !reflect.DeepEqual(owner.ProductionFiles, wantFiles) { + t.Fatalf("grammar owner = %#v, want package identity and exact production inventory %v", owner, wantFiles) + } + if !sort.StringsAreSorted(owner.ProductionFiles) { + t.Fatalf("grammar owner production files are not sorted: %v", owner.ProductionFiles) + } + packagePath, err := filepath.Abs(".") + if err != nil { + t.Fatal(err) + } + repositoryRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + relative, err := filepath.Rel(repositoryRoot, packagePath) if err != nil { t.Fatal(err) } - want := []string{"internal/kernel/requirementsourcecodec/types.go"} - if !reflect.DeepEqual(owners, want) { - t.Fatalf("v2 grammar identity owners = %v, want %v", owners, want) + if filepath.ToSlash(relative) != owner.OwnerPackage { + t.Fatalf("grammar owner package = %q, actual package = %q", owner.OwnerPackage, filepath.ToSlash(relative)) } } diff --git a/internal/kernel/requirementsourcecodec/selection_evaluator_test.go b/internal/kernel/requirementsourcecodec/selection_evaluator_test.go index 76c78d2..5ba9c0d 100644 --- a/internal/kernel/requirementsourcecodec/selection_evaluator_test.go +++ b/internal/kernel/requirementsourcecodec/selection_evaluator_test.go @@ -1,45 +1,98 @@ package requirementsourcecodec import ( + "reflect" "sort" "testing" ) +var selectionMetricSemantics = []selectionMetric{ + {MetricID: "aggregate_diff_regression_basis_points", Stage: "replacement", Role: "hard", Direction: "minimize", Baseline: "selected-json-layout", Aggregation: "maximum-aggregate-diff-regression", Requirement: "at-most-policy-threshold", Missing: "reject", MaterialThreshold: 500}, + {MetricID: "changed_bytes", Stage: "screen", Role: "primary", Direction: "minimize", Baseline: "eligible-json-layouts", Aggregation: "sum-over-frozen-edits", Requirement: "lexicographic-minimum", Missing: "reject", MaterialThreshold: 0}, + {MetricID: "changed_lines", Stage: "screen", Role: "primary", Direction: "minimize", Baseline: "eligible-json-layouts", Aggregation: "sum-over-frozen-edits", Requirement: "lexicographic-minimum", Missing: "reject", MaterialThreshold: 0}, + {MetricID: "edit_locality", Stage: "screen", Role: "hard", Direction: "equal", Baseline: "affected-entity-registry", Aggregation: "all-frozen-edits", Requirement: "true", Missing: "fail", MaterialThreshold: 0}, + {MetricID: "field_closure", Stage: "screen", Role: "hard", Direction: "equal", Baseline: "codec-field-manifest-v1", Aggregation: "all-fields", Requirement: "passed", Missing: "fail", MaterialThreshold: 0}, + {MetricID: "format_time_state", Stage: "replacement", Role: "hard", Direction: "equal", Baseline: "selected-grouped-json-v1", Aggregation: "paired-randomized-confidence-bound", Requirement: "passed", Missing: "reject", MaterialThreshold: 0}, + {MetricID: "invalid_mutation_false_accepts", Stage: "screen", Role: "hard", Direction: "minimize", Baseline: "frozen-invalid-review-task", Aggregation: "sum", Requirement: "zero", Missing: "fail", MaterialThreshold: 0}, + {MetricID: "lower_cost_dominance_state", Stage: "replacement", Role: "hard", Direction: "equal", Baseline: "eligible-lower-cost-comparators", Aggregation: "all-primary-metrics", Requirement: "passed", Missing: "reject", MaterialThreshold: 0}, + {MetricID: "parse_time_state", Stage: "replacement", Role: "hard", Direction: "equal", Baseline: "selected-grouped-json-v1", Aggregation: "paired-randomized-confidence-bound", Requirement: "passed", Missing: "reject", MaterialThreshold: 0}, + {MetricID: "per_edit_diff_regression_basis_points", Stage: "replacement", Role: "hard", Direction: "minimize", Baseline: "selected-json-layout", Aggregation: "maximum-per-edit-class-diff-regression", Requirement: "at-most-policy-threshold", Missing: "reject", MaterialThreshold: 1500}, + {MetricID: "projected_production_branches", Stage: "replacement", Role: "hard", Direction: "minimize", Baseline: "selected-json-layout", Aggregation: "estimate", Requirement: "at-most-policy-ratio", Missing: "reject", MaterialThreshold: 15000}, + {MetricID: "projected_production_loc", Stage: "replacement", Role: "hard", Direction: "minimize", Baseline: "selected-json-layout", Aggregation: "estimate", Requirement: "at-most-policy-ratio", Missing: "reject", MaterialThreshold: 15000}, + {MetricID: "review_accuracy_basis_points", Stage: "screen", Role: "hard", Direction: "maximize", Baseline: "maximum-observed-json-layout", Aggregation: "exact-gold-answers", Requirement: "equal-to-maximum", Missing: "fail", MaterialThreshold: 0}, + {MetricID: "weighted_canonical_bytes", Stage: "screen", Role: "primary", Direction: "minimize", Baseline: "eligible-json-layouts", Aggregation: "weighted-sum", Requirement: "lexicographic-minimum", Missing: "reject", MaterialThreshold: 0}, + {MetricID: "weighted_tokens_o200k_base", Stage: "screen", Role: "primary", Direction: "minimize", Baseline: "eligible-json-layouts", Aggregation: "weighted-sum", Requirement: "lexicographic-minimum", Missing: "reject", MaterialThreshold: 0}, +} + +func admittedSelectionMetricRegistry(metrics []selectionMetric) (map[string]selectionMetric, bool) { + if !reflect.DeepEqual(metrics, selectionMetricSemantics) { + return nil, false + } + result := make(map[string]selectionMetric, len(metrics)) + for _, metric := range metrics { + result[metric.MetricID] = metric + } + return result, true +} + func selectJSONLayout(record codecSelection) string { - maximumAccuracy := 0 - jsonIDs := stringSet(record.Roles.JSONLayouts) - for _, observation := range record.ScreenObservations { - if _, exists := jsonIDs[observation.CandidateID]; exists && observation.ReviewAccuracyBasisPoints != nil && *observation.ReviewAccuracyBasisPoints > maximumAccuracy { - maximumAccuracy = *observation.ReviewAccuracyBasisPoints - } + eligibility, eligibilityOK := jsonLayoutEligibility(record) + if !eligibilityOK { + return "" } - eligible := []screenObservation{} + eligibleRows := []screenObservation{} for _, observation := range record.ScreenObservations { - _, isJSON := jsonIDs[observation.CandidateID] - if isJSON && observation.FieldClosure == "passed" && observation.ReviewAccuracyBasisPoints != nil && *observation.ReviewAccuracyBasisPoints == maximumAccuracy && observation.InvalidMutationFalseAccepts != nil && *observation.InvalidMutationFalseAccepts == 0 && observation.EditLocality { - eligible = append(eligible, observation) + if eligibility[observation.CandidateID] { + eligibleRows = append(eligibleRows, observation) } } - sort.Slice(eligible, func(left, right int) bool { + sort.Slice(eligibleRows, func(left, right int) bool { for _, metricID := range record.JSONLayoutOrder { if metricID == "candidate_id" { - return eligible[left].CandidateID < eligible[right].CandidateID + return eligibleRows[left].CandidateID < eligibleRows[right].CandidateID } - leftValue := layoutMetricValue(eligible[left], metricID) - rightValue := layoutMetricValue(eligible[right], metricID) + leftValue := layoutMetricValue(eligibleRows[left], metricID) + rightValue := layoutMetricValue(eligibleRows[right], metricID) if leftValue != rightValue { return leftValue < rightValue } } return false }) - if len(eligible) == 0 { + if len(eligibleRows) == 0 { return "" } - return eligible[0].CandidateID + return eligibleRows[0].CandidateID +} + +func jsonLayoutEligibility(record codecSelection) (map[string]bool, bool) { + metrics, ok := admittedSelectionMetricRegistry(record.MetricRegistry) + if !ok || !jsonLayoutOrderIsAdmitted(record.JSONLayoutOrder, metrics) { + return nil, false + } + maximumAccuracy := 0 + jsonIDs := stringSet(record.Roles.JSONLayouts) + for _, observation := range record.ScreenObservations { + if _, exists := jsonIDs[observation.CandidateID]; exists && observation.ReviewAccuracyBasisPoints != nil && *observation.ReviewAccuracyBasisPoints > maximumAccuracy { + maximumAccuracy = *observation.ReviewAccuracyBasisPoints + } + } + result := make(map[string]bool, len(record.Roles.JSONLayouts)) + for _, candidateID := range record.Roles.JSONLayouts { + observation, exists := observationByID(record.ScreenObservations, candidateID) + if !exists { + return nil, false + } + result[candidateID] = jsonLayoutPassesHardMetrics(observation, maximumAccuracy, metrics) + } + return result, true } func challengerEligible(record codecSelection) bool { + metrics, ok := admittedSelectionMetricRegistry(record.MetricRegistry) + if !ok { + return false + } selectedJSON := selectJSONLayout(record) if selectedJSON == "" || len(record.Roles.RestrictedTextChallengers) != 1 { return false @@ -50,18 +103,112 @@ func challengerEligible(record codecSelection) bool { return false } policy := record.ReplacementPolicy - return challenger.FieldClosure == "passed" && - challenger.ReviewAccuracyBasisPoints != nil && *challenger.ReviewAccuracyBasisPoints >= policy.MinimumReviewAccuracyBasisPoints && - challenger.InvalidMutationFalseAccepts != nil && *challenger.InvalidMutationFalseAccepts <= policy.MaximumInvalidMutationFalseAccepts && - challenger.EditLocality && + return challengerPassesScreenMetrics(challenger, policy, metrics) && materiallyBetter(baseline.WeightedCanonicalBytes, challenger.WeightedCanonicalBytes, policy.MinimumByteImprovementBasisPoints) && materiallyBetter(baseline.WeightedTokensO200kBase, challenger.WeightedTokensO200kBase, policy.MinimumTokenImprovementBasisPoints) && - challenger.AggregateDiffRegressionBasisPoints != nil && *challenger.AggregateDiffRegressionBasisPoints <= policy.MaximumAggregateDiffRegressionBasisPoints && - challenger.PerEditDiffRegressionBasisPoints != nil && *challenger.PerEditDiffRegressionBasisPoints <= policy.MaximumPerEditDiffRegressionBasisPoints && - challenger.ParseTimeState == "passed" && challenger.FormatTimeState == "passed" && - challenger.LowerCostDominanceState == "passed" && - withinRatio(baseline.ProjectedProductionLOC, challenger.ProjectedProductionLOC, policy.MaximumProjectedProductionCostBasisPoints) && - withinRatio(baseline.ProjectedProductionBranches, challenger.ProjectedProductionBranches, policy.MaximumProjectedProductionCostBasisPoints) + challengerPassesReplacementMetrics(baseline, challenger, policy, metrics) +} + +func jsonLayoutOrderIsAdmitted(order []string, metrics map[string]selectionMetric) bool { + for _, metricID := range order { + if metricID == "candidate_id" { + continue + } + metric, exists := metrics[metricID] + if !exists || metric.Stage != "screen" || metric.Role != "primary" || metric.Direction != "minimize" || metric.Requirement != "lexicographic-minimum" { + return false + } + } + return reflect.DeepEqual(order, []string{"weighted_tokens_o200k_base", "weighted_canonical_bytes", "changed_bytes", "changed_lines", "candidate_id"}) +} + +func jsonLayoutPassesHardMetrics(observation screenObservation, maximumAccuracy int, metrics map[string]selectionMetric) bool { + for _, metric := range metrics { + if metric.Stage != "screen" || metric.Role != "hard" { + continue + } + switch metric.MetricID { + case "field_closure": + if observation.FieldClosure != metric.Requirement { + return false + } + case "review_accuracy_basis_points": + if observation.ReviewAccuracyBasisPoints == nil || *observation.ReviewAccuracyBasisPoints != maximumAccuracy { + return false + } + case "invalid_mutation_false_accepts": + if observation.InvalidMutationFalseAccepts == nil || *observation.InvalidMutationFalseAccepts != metric.MaterialThreshold { + return false + } + case "edit_locality": + if !observation.EditLocality { + return false + } + default: + return false + } + } + return true +} + +func challengerPassesScreenMetrics(observation screenObservation, policy replacementPolicy, metrics map[string]selectionMetric) bool { + for _, metric := range metrics { + if metric.Stage != "screen" || metric.Role != "hard" { + continue + } + switch metric.MetricID { + case "field_closure": + if observation.FieldClosure != metric.Requirement { + return false + } + case "review_accuracy_basis_points": + if observation.ReviewAccuracyBasisPoints == nil || *observation.ReviewAccuracyBasisPoints < policy.MinimumReviewAccuracyBasisPoints { + return false + } + case "invalid_mutation_false_accepts": + if observation.InvalidMutationFalseAccepts == nil || *observation.InvalidMutationFalseAccepts > policy.MaximumInvalidMutationFalseAccepts { + return false + } + case "edit_locality": + if !observation.EditLocality { + return false + } + default: + return false + } + } + return true +} + +func challengerPassesReplacementMetrics(baseline screenObservation, challenger screenObservation, policy replacementPolicy, metrics map[string]selectionMetric) bool { + for _, metric := range metrics { + if metric.Stage != "replacement" || metric.Role != "hard" { + continue + } + passed := false + switch metric.MetricID { + case "aggregate_diff_regression_basis_points": + passed = challenger.AggregateDiffRegressionBasisPoints != nil && *challenger.AggregateDiffRegressionBasisPoints <= policy.MaximumAggregateDiffRegressionBasisPoints + case "per_edit_diff_regression_basis_points": + passed = challenger.PerEditDiffRegressionBasisPoints != nil && *challenger.PerEditDiffRegressionBasisPoints <= policy.MaximumPerEditDiffRegressionBasisPoints + case "parse_time_state": + passed = challenger.ParseTimeState == metric.Requirement + case "format_time_state": + passed = challenger.FormatTimeState == metric.Requirement + case "lower_cost_dominance_state": + passed = challenger.LowerCostDominanceState == metric.Requirement + case "projected_production_loc": + passed = withinRatio(baseline.ProjectedProductionLOC, challenger.ProjectedProductionLOC, policy.MaximumProjectedProductionCostBasisPoints) + case "projected_production_branches": + passed = withinRatio(baseline.ProjectedProductionBranches, challenger.ProjectedProductionBranches, policy.MaximumProjectedProductionCostBasisPoints) + default: + return false + } + if !passed { + return false + } + } + return true } func layoutMetricValue(value screenObservation, metricID string) int { @@ -148,6 +295,30 @@ func TestChallengerEligibilityRequiresEveryReplacementPredicate(t *testing.T) { } } +func TestSelectionMetricRegistrySemanticsDriveEvaluator(t *testing.T) { + record := readCodecSelection(t) + if selectJSONLayout(record) == "" { + t.Fatal("admitted metric registry did not produce a JSON selection") + } + + roleDrift := record + roleDrift.MetricRegistry = append([]selectionMetric(nil), record.MetricRegistry...) + for index := range roleDrift.MetricRegistry { + if roleDrift.MetricRegistry[index].MetricID == "review_accuracy_basis_points" { + roleDrift.MetricRegistry[index].Role = "report-only" + } + } + if selectJSONLayout(roleDrift) != "" || challengerEligible(roleDrift) { + t.Fatal("evaluator accepted a registry that downgraded a hard metric") + } + + missingMetric := record + missingMetric.MetricRegistry = append([]selectionMetric(nil), record.MetricRegistry[:len(record.MetricRegistry)-1]...) + if selectJSONLayout(missingMetric) != "" || challengerEligible(missingMetric) { + t.Fatal("evaluator accepted an incomplete metric registry") + } +} + func eligibleChallengerRecord(t *testing.T) codecSelection { t.Helper() record := readCodecSelection(t) diff --git a/internal/kernel/requirementsourcecodec/selection_evidence_test.go b/internal/kernel/requirementsourcecodec/selection_evidence_test.go index 9c56976..b459256 100644 --- a/internal/kernel/requirementsourcecodec/selection_evidence_test.go +++ b/internal/kernel/requirementsourcecodec/selection_evidence_test.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "io/fs" @@ -17,6 +18,8 @@ import ( "strings" "testing" "testing/fstest" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" ) const ( @@ -67,37 +70,6 @@ type tokenReportEvidence struct { } `json:"records"` } -type screenDecisionEvidence struct { - SchemaVersion int `json:"schemaVersion"` - Kind string `json:"kind"` - ManifestSHA256 string `json:"manifestSha256"` - ObservationSHA256 string `json:"observationSha256"` - OpeningSHA256 string `json:"openingSha256"` - ReviewResultsSHA256 string `json:"reviewResultsSha256"` - TokenReportSHA256 map[string]string `json:"tokenReportSha256"` - ValidationSHA256 string `json:"validationSha256"` - State string `json:"state"` - SelectedJSONLayout string `json:"selectedJsonLayout"` - SelectedChallenger *string `json:"selectedChallenger"` - Candidates []struct { - CandidateID string `json:"candidateId"` - WeightedCanonicalBytes int `json:"weightedCanonicalBytes"` - WeightedTokensO200kBase int `json:"weightedTokensO200kBase"` - ChangedLines int `json:"changedLines"` - ChangedBytes int `json:"changedBytes"` - EditLocality bool `json:"editLocality"` - JSONFieldClosure string `json:"jsonFieldClosure"` - Review *struct { - AccuracyBasisPoints int `json:"accuracyBasisPoints"` - InvalidMutationFalseAccepts int `json:"invalidMutationFalseAccepts"` - } `json:"review"` - ProjectedProduction struct { - LOC int `json:"loc"` - Branches int `json:"branches"` - } `json:"projectedProduction"` - } `json:"candidates"` -} - func TestSelectionEvidenceIsByteBoundAndProjectsDecision(t *testing.T) { record := readCodecSelection(t) root := readScreenArchive(t, record.ScreenEvidence) @@ -139,9 +111,11 @@ func TestSelectionEvidenceIsByteBoundAndProjectsDecision(t *testing.T) { } } - decision := readEvidence[screenDecisionEvidence](t, root, artifacts["screen-decision"].Path) + decision := readStrictEvidence[screenDecisionEvidence](t, root, artifacts["screen-decision"].Path) assertDecisionLinks(t, decision, artifacts) - assertDecisionProjection(t, record, decision) + if err := verifyDecisionProjection(record, decision); err != nil { + t.Fatal(err) + } } func TestScreenTreeDigestRejectsByteAndInventoryMutation(t *testing.T) { @@ -183,6 +157,16 @@ func TestScreenArchiveAdmissionRejectsUnsafeTopology(t *testing.T) { } } +func TestScreenArchiveAdmissionRejectsTrailingCompressedData(t *testing.T) { + first := screenArchiveFixture(t, []tar.Header{{Name: "entry", Mode: 0o444, Size: 1, Typeflag: tar.TypeReg}}) + second := screenArchiveFixture(t, []tar.Header{{Name: "link", Linkname: "../escape", Mode: 0o777, Typeflag: tar.TypeSymlink}}) + payload := append(append([]byte(nil), first...), second...) + _, err := decodeScreenArchive(payload) + if err == nil || !strings.Contains(err.Error(), "trailing compressed data") { + t.Fatalf("decodeScreenArchive() error = %v, want trailing compressed data rejection", err) + } +} + func verifyScreenArtifacts(t *testing.T, root fs.FS, values []screenArtifact) map[string]screenArtifact { t.Helper() result := make(map[string]screenArtifact, len(values)) @@ -201,14 +185,21 @@ func assertDecisionLinks(t *testing.T, value screenDecisionEvidence, artifacts m if value.SchemaVersion != 3 || value.Kind != "proofkit.requirement-source-codec-screen-decision" { t.Fatalf("screen decision identity = %#v", value) } + wantTokenReports := map[string]string{ + "openai-tiktoken-0.14.0": artifacts["token-python"].SHA256, + "tiktoken-go-0.8.1": artifacts["token-go"].SHA256, + } + if !reflect.DeepEqual(value.TokenReportSHA256, wantTokenReports) { + t.Fatalf("decision token-report links = %v, want %v", value.TokenReportSHA256, wantTokenReports) + } want := map[string]string{ "screen-manifest": value.ManifestSHA256, "observations": value.ObservationSHA256, "selection-opening": value.OpeningSHA256, "review-results": value.ReviewResultsSHA256, "independent-validation": value.ValidationSHA256, - "token-python": value.TokenReportSHA256["openai-tiktoken-0.14.0"], - "token-go": value.TokenReportSHA256["tiktoken-go-0.8.1"], + "token-python": wantTokenReports["openai-tiktoken-0.14.0"], + "token-go": wantTokenReports["tiktoken-go-0.8.1"], } for role, digest := range want { if artifacts[role].SHA256 != digest { @@ -217,46 +208,6 @@ func assertDecisionLinks(t *testing.T, value screenDecisionEvidence, artifacts m } } -func assertDecisionProjection(t *testing.T, record codecSelection, decision screenDecisionEvidence) { - t.Helper() - if decision.State != record.Decision.State || decision.SelectedJSONLayout != record.Decision.SelectedJSONLayout || !reflect.DeepEqual(decision.SelectedChallenger, record.Decision.SelectedChallenger) { - t.Fatalf("screen decision projection mismatch: %#v", decision) - } - byID := make(map[string]screenObservation, len(record.ScreenObservations)) - for _, item := range record.ScreenObservations { - byID[item.CandidateID] = item - } - seen := make(map[string]struct{}, len(decision.Candidates)) - for _, candidate := range decision.Candidates { - if _, duplicate := seen[candidate.CandidateID]; duplicate { - t.Fatalf("decision repeats candidate %q", candidate.CandidateID) - } - seen[candidate.CandidateID] = struct{}{} - observation, exists := byID[candidate.CandidateID] - if !exists { - t.Fatalf("decision contains unknown candidate %q", candidate.CandidateID) - } - if observation.WeightedCanonicalBytes != candidate.WeightedCanonicalBytes || observation.WeightedTokensO200kBase != candidate.WeightedTokensO200kBase || - observation.ChangedLines != candidate.ChangedLines || observation.ChangedBytes != candidate.ChangedBytes || observation.EditLocality != candidate.EditLocality || - observation.ProjectedProductionLOC != candidate.ProjectedProduction.LOC || observation.ProjectedProductionBranches != candidate.ProjectedProduction.Branches { - t.Fatalf("candidate %q observation drift", candidate.CandidateID) - } - if candidate.JSONFieldClosure != "" && observation.FieldClosure != candidate.JSONFieldClosure { - t.Fatalf("candidate %q field closure drift", candidate.CandidateID) - } - if candidate.Review != nil && (observation.ReviewAccuracyBasisPoints == nil || observation.InvalidMutationFalseAccepts == nil || - *observation.ReviewAccuracyBasisPoints != candidate.Review.AccuracyBasisPoints || *observation.InvalidMutationFalseAccepts != candidate.Review.InvalidMutationFalseAccepts) { - t.Fatalf("candidate %q review projection drift", candidate.CandidateID) - } - if candidate.Review == nil && (observation.ReviewAccuracyBasisPoints != nil || observation.InvalidMutationFalseAccepts != nil) { - t.Fatalf("candidate %q unexpected review projection", candidate.CandidateID) - } - } - if len(decision.Candidates) != len(record.ScreenObservations) { - t.Fatalf("decision candidate count = %d, want %d", len(decision.Candidates), len(record.ScreenObservations)) - } -} - func assertBoundFile(t *testing.T, root fs.FS, value boundFile) { t.Helper() if value.Path == "" || path.IsAbs(value.Path) || path.Clean(value.Path) != value.Path || value.Path == ".." || len(value.Path) > 3 && value.Path[:3] == "../" { @@ -285,6 +236,25 @@ func readEvidence[T any](t *testing.T, root fs.FS, filePath string) T { return result } +func readStrictEvidence[T any](t *testing.T, root fs.FS, filePath string) T { + t.Helper() + payload, err := fs.ReadFile(root, filePath) + if err != nil { + t.Fatal(err) + } + result, err := admission.DecodeTypedJSON[T](bytes.NewReader(payload), int64(len(payload))) + if err != nil { + t.Fatal(err) + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + var strict T + if err := decoder.Decode(&strict); err != nil { + t.Fatal(err) + } + return result +} + func readScreenArchive(t *testing.T, evidence screenEvidence) fstest.MapFS { t.Helper() payload, err := os.ReadFile(evidence.ArchivePath) @@ -306,7 +276,8 @@ func readScreenArchive(t *testing.T, evidence screenEvidence) fstest.MapFS { } func decodeScreenArchive(payload []byte) (fstest.MapFS, error) { - compressed, err := gzip.NewReader(bytes.NewReader(payload)) + remaining := bytes.NewReader(payload) + compressed, err := gzip.NewReader(remaining) if err != nil { return nil, err } @@ -353,6 +324,16 @@ func decodeScreenArchive(payload []byte) (fstest.MapFS, error) { totalBytes += header.Size result[clean] = &fstest.MapFile{Data: content, Mode: 0o444} } + trailingUncompressed, err := io.Copy(io.Discard, compressed) + if err != nil { + return nil, err + } + if trailingUncompressed != 0 { + return nil, errors.New("screen archive has trailing uncompressed data") + } + if remaining.Len() != 0 { + return nil, errors.New("screen archive has trailing compressed data") + } return result, nil } diff --git a/internal/kernel/requirementsourcecodec/selection_projection_test.go b/internal/kernel/requirementsourcecodec/selection_projection_test.go new file mode 100644 index 0000000..d64b0f4 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/selection_projection_test.go @@ -0,0 +1,400 @@ +package requirementsourcecodec + +import ( + "errors" + "fmt" + "reflect" + "sort" + "testing" +) + +type screenDecisionEvidence struct { + SchemaVersion int `json:"schemaVersion"` + Kind string `json:"kind"` + ManifestSHA256 string `json:"manifestSha256"` + ObservationSHA256 string `json:"observationSha256"` + OpeningSHA256 string `json:"openingSha256"` + ReviewResultsSHA256 string `json:"reviewResultsSha256"` + TokenReportSHA256 map[string]string `json:"tokenReportSha256"` + ValidationSHA256 string `json:"validationSha256"` + State string `json:"state"` + SelectedJSONLayout string `json:"selectedJsonLayout"` + SelectedChallenger *string `json:"selectedChallenger"` + Candidates []screenDecisionCandidate `json:"candidates"` + JSONEligibility map[string]bool `json:"jsonEligibility"` + ChallengerPredicates screenChallengerPredicates `json:"challengerPredicates"` + StageECandidates []string `json:"stageECandidates"` +} + +type screenDecisionCandidate struct { + CandidateID string `json:"candidateId"` + WeightedCanonicalBytes int `json:"weightedCanonicalBytes"` + WeightedTokensO200kBase int `json:"weightedTokensO200kBase"` + ChangedLines int `json:"changedLines"` + ChangedBytes int `json:"changedBytes"` + EditLocality bool `json:"editLocality"` + EditRows []screenDecisionEditRow `json:"editRows"` + JSONFieldClosure string `json:"jsonFieldClosure"` + Review *screenDecisionReview `json:"review"` + ProjectedProduction projectedProduction `json:"projectedProduction"` +} + +type screenDecisionEditRow struct { + EditID string `json:"editId"` + ChangedLines int `json:"changedLines"` + ChangedBytes int `json:"changedBytes"` + LocalityViolations []string `json:"localityViolations"` +} + +type screenDecisionReview struct { + Correct int `json:"correct"` + Total int `json:"total"` + AccuracyBasisPoints int `json:"accuracyBasisPoints"` + InvalidMutationFalseAccepts int `json:"invalidMutationFalseAccepts"` + SlotID string `json:"slotId"` + AgentID string `json:"agentId"` +} + +type projectedProduction struct { + LOC int `json:"loc"` + Branches int `json:"branches"` + Basis string `json:"basis"` +} + +type screenChallengerPredicates struct { + GroupedJSONAccepted bool `json:"groupedJsonAccepted"` + ReviewPresent bool `json:"reviewPresent"` + ReviewAccuracy bool `json:"reviewAccuracy"` + InvalidMutationFalseAccepts bool `json:"invalidMutationFalseAccepts"` + EditLocality bool `json:"editLocality"` + ByteImprovement bool `json:"byteImprovement"` + TokenImprovement bool `json:"tokenImprovement"` + AggregateDiffNoninferior bool `json:"aggregateDiffNoninferior"` + PerEditDiffNoninferior bool `json:"perEditDiffNoninferior"` + ProjectedProductionCost bool `json:"projectedProductionCost"` + LowerCostComparisonComplete bool `json:"lowerCostComparisonComplete"` + StrictlyDominatesLowerCostComparators bool `json:"strictlyDominatesLowerCostComparators"` +} + +func TestDecisionProjectionRejectsReplacementObservationDrift(t *testing.T) { + record := readCodecSelection(t) + root := readScreenArchive(t, record.ScreenEvidence) + artifacts := verifyScreenArtifacts(t, root, record.ScreenEvidence.Artifacts) + decision := readStrictEvidence[screenDecisionEvidence](t, root, artifacts["screen-decision"].Path) + if err := verifyDecisionProjection(record, decision); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + mutate func(*codecSelection, *screenDecisionEvidence) + }{ + {name: "aggregate diff", mutate: func(value *codecSelection, _ *screenDecisionEvidence) { + mutateObservation(value, "proofkit-source-text-v1", func(item *screenObservation) { item.AggregateDiffRegressionBasisPoints = integerPointer(0) }) + }}, + {name: "per-edit diff", mutate: func(value *codecSelection, _ *screenDecisionEvidence) { + mutateObservation(value, "proofkit-source-text-v1", func(item *screenObservation) { item.PerEditDiffRegressionBasisPoints = integerPointer(0) }) + }}, + {name: "missing parse measurement", mutate: func(value *codecSelection, _ *screenDecisionEvidence) { + mutateObservation(value, "proofkit-source-text-v1", func(item *screenObservation) { item.ParseTimeState = "passed" }) + }}, + {name: "JSON eligibility", mutate: func(_ *codecSelection, value *screenDecisionEvidence) { + value.JSONEligibility = cloneBoolMap(value.JSONEligibility) + value.JSONEligibility["json-compact-v1"] = true + }}, + {name: "challenger predicate", mutate: func(_ *codecSelection, value *screenDecisionEvidence) { + value.ChallengerPredicates.AggregateDiffNoninferior = true + }}, + } + for _, item := range tests { + t.Run(item.name, func(t *testing.T) { + mutatedRecord := readCodecSelection(t) + mutatedRecord.ScreenObservations = append([]screenObservation(nil), mutatedRecord.ScreenObservations...) + mutatedDecision := decision + item.mutate(&mutatedRecord, &mutatedDecision) + if err := verifyDecisionProjection(mutatedRecord, mutatedDecision); err == nil { + t.Fatal("decision projection admitted a causal evidence mutation") + } + }) + } +} + +func verifyDecisionProjection(record codecSelection, decision screenDecisionEvidence) error { + if decision.State != record.Decision.State || decision.SelectedJSONLayout != record.Decision.SelectedJSONLayout || !reflect.DeepEqual(decision.SelectedChallenger, record.Decision.SelectedChallenger) { + return fmt.Errorf("screen decision projection mismatch: %#v", decision) + } + eligibility, ok := jsonLayoutEligibility(record) + if !ok || !reflect.DeepEqual(decision.JSONEligibility, eligibility) { + return fmt.Errorf("screen JSON eligibility = %v, want %v", decision.JSONEligibility, eligibility) + } + wantStageE := []string{record.Decision.SelectedCodec} + if record.Decision.SelectedChallenger != nil { + wantStageE = append(wantStageE, *record.Decision.SelectedChallenger) + } + if !reflect.DeepEqual(decision.StageECandidates, wantStageE) { + return fmt.Errorf("screen stage-E candidates = %v, want %v", decision.StageECandidates, wantStageE) + } + byID := make(map[string]screenObservation, len(record.ScreenObservations)) + for _, item := range record.ScreenObservations { + if _, duplicate := byID[item.CandidateID]; duplicate { + return fmt.Errorf("selection repeats candidate %q", item.CandidateID) + } + byID[item.CandidateID] = item + } + decisionByID := make(map[string]screenDecisionCandidate, len(decision.Candidates)) + previousCandidateID := "" + for _, candidate := range decision.Candidates { + if candidate.CandidateID == "" || candidate.CandidateID <= previousCandidateID { + return errors.New("screen decision candidates are not sorted unique") + } + if _, duplicate := decisionByID[candidate.CandidateID]; duplicate { + return fmt.Errorf("decision repeats candidate %q", candidate.CandidateID) + } + decisionByID[candidate.CandidateID] = candidate + previousCandidateID = candidate.CandidateID + observation, exists := byID[candidate.CandidateID] + if !exists { + return fmt.Errorf("decision contains unknown candidate %q", candidate.CandidateID) + } + if observation.WeightedCanonicalBytes != candidate.WeightedCanonicalBytes || observation.WeightedTokensO200kBase != candidate.WeightedTokensO200kBase || + observation.ChangedLines != candidate.ChangedLines || observation.ChangedBytes != candidate.ChangedBytes || observation.EditLocality != candidate.EditLocality || + observation.ProjectedProductionLOC != candidate.ProjectedProduction.LOC || observation.ProjectedProductionBranches != candidate.ProjectedProduction.Branches { + return fmt.Errorf("candidate %q observation drift", candidate.CandidateID) + } + if observation.FieldClosure != candidate.JSONFieldClosure { + return fmt.Errorf("candidate %q field closure drift", candidate.CandidateID) + } + if candidate.Review != nil && (observation.ReviewAccuracyBasisPoints == nil || observation.InvalidMutationFalseAccepts == nil || + *observation.ReviewAccuracyBasisPoints != candidate.Review.AccuracyBasisPoints || *observation.InvalidMutationFalseAccepts != candidate.Review.InvalidMutationFalseAccepts) { + return fmt.Errorf("candidate %q review projection drift", candidate.CandidateID) + } + if candidate.Review != nil && (candidate.Review.Total <= 0 || candidate.Review.Correct < 0 || candidate.Review.Correct > candidate.Review.Total || + candidate.Review.AccuracyBasisPoints != candidate.Review.Correct*10000/candidate.Review.Total || candidate.Review.InvalidMutationFalseAccepts < 0 || + candidate.Review.SlotID == "" || candidate.Review.AgentID == "") { + return fmt.Errorf("candidate %q has internally inconsistent review evidence", candidate.CandidateID) + } + if candidate.Review == nil && (observation.ReviewAccuracyBasisPoints != nil || observation.InvalidMutationFalseAccepts != nil) { + return fmt.Errorf("candidate %q unexpected review projection", candidate.CandidateID) + } + if candidate.ProjectedProduction.Basis == "" { + return fmt.Errorf("candidate %q lacks projected-production basis", candidate.CandidateID) + } + if err := verifyDecisionEditRows(candidate); err != nil { + return err + } + } + if len(decision.Candidates) != len(record.ScreenObservations) { + return fmt.Errorf("decision candidate count = %d, want %d", len(decision.Candidates), len(record.ScreenObservations)) + } + wantPredicates, err := frozenScreenChallengerPredicates(record, decisionByID) + if err != nil { + return err + } + if decision.ChallengerPredicates != wantPredicates { + return fmt.Errorf("screen challenger predicates = %#v, want %#v", decision.ChallengerPredicates, wantPredicates) + } + return verifyReplacementProjection(record, decisionByID, decision.ChallengerPredicates) +} + +func verifyDecisionEditRows(candidate screenDecisionCandidate) error { + if len(candidate.EditRows) == 0 { + return fmt.Errorf("candidate %q has no frozen edit rows", candidate.CandidateID) + } + seen := make(map[string]struct{}, len(candidate.EditRows)) + changedLines := 0 + changedBytes := 0 + local := true + previous := "" + for _, row := range candidate.EditRows { + if row.EditID == "" || row.EditID <= previous { + return fmt.Errorf("candidate %q edit rows are not sorted unique", candidate.CandidateID) + } + if _, duplicate := seen[row.EditID]; duplicate { + return fmt.Errorf("candidate %q repeats edit %q", candidate.CandidateID, row.EditID) + } + if row.ChangedLines < 0 || row.ChangedBytes < 0 { + return fmt.Errorf("candidate %q edit %q has negative measurements", candidate.CandidateID, row.EditID) + } + if !sort.StringsAreSorted(row.LocalityViolations) { + return fmt.Errorf("candidate %q edit %q locality violations are not sorted", candidate.CandidateID, row.EditID) + } + for index, violation := range row.LocalityViolations { + if violation == "" || index > 0 && violation == row.LocalityViolations[index-1] { + return fmt.Errorf("candidate %q edit %q has invalid locality violations", candidate.CandidateID, row.EditID) + } + } + seen[row.EditID] = struct{}{} + previous = row.EditID + changedLines += row.ChangedLines + changedBytes += row.ChangedBytes + local = local && len(row.LocalityViolations) == 0 + } + if changedLines != candidate.ChangedLines || changedBytes != candidate.ChangedBytes || local != candidate.EditLocality { + return fmt.Errorf("candidate %q edit projection does not close aggregate measurements", candidate.CandidateID) + } + return nil +} + +func frozenScreenChallengerPredicates(record codecSelection, candidates map[string]screenDecisionCandidate) (screenChallengerPredicates, error) { + if len(record.Roles.RestrictedTextChallengers) != 1 { + return screenChallengerPredicates{}, errors.New("screen must have exactly one restricted-text challenger") + } + baseline, baselineExists := candidates[record.Decision.SelectedJSONLayout] + challenger, challengerExists := candidates[record.Roles.RestrictedTextChallengers[0]] + if !baselineExists || !challengerExists { + return screenChallengerPredicates{}, errors.New("screen decision lacks baseline or challenger") + } + lowerCostRows := make([]screenDecisionCandidate, 0, len(record.Roles.ScreenOnlyComparators)) + for _, candidateID := range record.Roles.ScreenOnlyComparators { + candidate, exists := candidates[candidateID] + if !exists { + return screenChallengerPredicates{}, fmt.Errorf("screen decision lacks lower-cost comparator %q", candidateID) + } + lowerCostRows = append(lowerCostRows, candidate) + } + lowerCostComplete := true + strictlyDominates := true + for _, candidate := range lowerCostRows { + if candidate.Review == nil { + lowerCostComplete = false + strictlyDominates = false + continue + } + if challenger.Review == nil || !dominatesEveryPrimaryMetric(challenger, candidate) { + strictlyDominates = false + } + } + return screenChallengerPredicates{ + GroupedJSONAccepted: record.Decision.SelectedJSONLayout != "", + ReviewPresent: challenger.Review != nil, + ReviewAccuracy: challenger.Review != nil && challenger.Review.AccuracyBasisPoints == record.ReplacementPolicy.MinimumReviewAccuracyBasisPoints, + InvalidMutationFalseAccepts: challenger.Review != nil && challenger.Review.InvalidMutationFalseAccepts == record.ReplacementPolicy.MaximumInvalidMutationFalseAccepts, + EditLocality: challenger.EditLocality, + ByteImprovement: materiallyBetter(baseline.WeightedCanonicalBytes, challenger.WeightedCanonicalBytes, record.ReplacementPolicy.MinimumByteImprovementBasisPoints), + TokenImprovement: materiallyBetter(baseline.WeightedTokensO200kBase, challenger.WeightedTokensO200kBase, record.ReplacementPolicy.MinimumTokenImprovementBasisPoints), + AggregateDiffNoninferior: regressionBasisPoints(baseline.ChangedBytes, challenger.ChangedBytes) <= record.ReplacementPolicy.MaximumAggregateDiffRegressionBasisPoints, + PerEditDiffNoninferior: perEditBytesNoninferior(baseline.EditRows, challenger.EditRows, record.ReplacementPolicy.MaximumPerEditDiffRegressionBasisPoints), + ProjectedProductionCost: regressionBasisPoints(baseline.ProjectedProduction.LOC, challenger.ProjectedProduction.LOC) <= record.ReplacementPolicy.MaximumProjectedProductionCostBasisPoints-10000, + LowerCostComparisonComplete: lowerCostComplete, + StrictlyDominatesLowerCostComparators: lowerCostComplete && strictlyDominates, + }, nil +} + +func dominatesEveryPrimaryMetric(challenger screenDecisionCandidate, candidate screenDecisionCandidate) bool { + if challenger.Review == nil || candidate.Review == nil { + return false + } + nonWorse := challenger.WeightedCanonicalBytes <= candidate.WeightedCanonicalBytes && + challenger.WeightedTokensO200kBase <= candidate.WeightedTokensO200kBase && + challenger.ChangedLines <= candidate.ChangedLines && challenger.ChangedBytes <= candidate.ChangedBytes && + challenger.Review.AccuracyBasisPoints >= candidate.Review.AccuracyBasisPoints + strict := challenger.WeightedCanonicalBytes < candidate.WeightedCanonicalBytes || + challenger.WeightedTokensO200kBase < candidate.WeightedTokensO200kBase || + challenger.ChangedLines < candidate.ChangedLines || challenger.ChangedBytes < candidate.ChangedBytes || + challenger.Review.AccuracyBasisPoints > candidate.Review.AccuracyBasisPoints + return nonWorse && strict +} + +func perEditBytesNoninferior(baselineRows []screenDecisionEditRow, challengerRows []screenDecisionEditRow, maximum int) bool { + baseline := make(map[string]screenDecisionEditRow, len(baselineRows)) + for _, row := range baselineRows { + baseline[row.EditID] = row + } + if len(baseline) != len(challengerRows) { + return false + } + for _, row := range challengerRows { + baselineRow, exists := baseline[row.EditID] + if !exists || regressionBasisPoints(baselineRow.ChangedBytes, row.ChangedBytes) > maximum { + return false + } + } + return true +} + +func verifyReplacementProjection(record codecSelection, candidates map[string]screenDecisionCandidate, predicates screenChallengerPredicates) error { + if len(record.Roles.RestrictedTextChallengers) != 1 { + return errors.New("selection must have exactly one restricted-text challenger") + } + baseline, baselineExists := candidates[record.Decision.SelectedJSONLayout] + challenger, challengerExists := candidates[record.Roles.RestrictedTextChallengers[0]] + observation, observationExists := observationByID(record.ScreenObservations, record.Roles.RestrictedTextChallengers[0]) + if !baselineExists || !challengerExists || !observationExists { + return errors.New("replacement projection lacks baseline or challenger") + } + aggregate := maximum( + regressionBasisPoints(baseline.ChangedLines, challenger.ChangedLines), + regressionBasisPoints(baseline.ChangedBytes, challenger.ChangedBytes), + ) + perEdit, err := maximumEditRegression(baseline.EditRows, challenger.EditRows) + if err != nil { + return err + } + if observation.AggregateDiffRegressionBasisPoints == nil || *observation.AggregateDiffRegressionBasisPoints != aggregate || + observation.PerEditDiffRegressionBasisPoints == nil || *observation.PerEditDiffRegressionBasisPoints != perEdit { + return fmt.Errorf("challenger replacement diff projection = aggregate %v per-edit %v, want %d and %d", observation.AggregateDiffRegressionBasisPoints, observation.PerEditDiffRegressionBasisPoints, aggregate, perEdit) + } + if observation.ParseTimeState != "missing" || observation.FormatTimeState != "missing" { + return fmt.Errorf("challenger unmeasured performance state = parse %q format %q, want missing", observation.ParseTimeState, observation.FormatTimeState) + } + wantDominance := "failed" + if predicates.LowerCostComparisonComplete && predicates.StrictlyDominatesLowerCostComparators { + wantDominance = "passed" + } + if observation.LowerCostDominanceState != wantDominance { + return fmt.Errorf("challenger lower-cost dominance = %q, want %q", observation.LowerCostDominanceState, wantDominance) + } + return nil +} + +func maximumEditRegression(baselineRows []screenDecisionEditRow, challengerRows []screenDecisionEditRow) (int, error) { + baseline := make(map[string]screenDecisionEditRow, len(baselineRows)) + for _, row := range baselineRows { + if _, duplicate := baseline[row.EditID]; duplicate { + return 0, fmt.Errorf("baseline repeats edit %q", row.EditID) + } + baseline[row.EditID] = row + } + if len(baseline) != len(challengerRows) { + return 0, errors.New("replacement edit sets differ") + } + result := 0 + seen := make(map[string]struct{}, len(challengerRows)) + for _, row := range challengerRows { + if _, duplicate := seen[row.EditID]; duplicate { + return 0, fmt.Errorf("challenger repeats edit %q", row.EditID) + } + seen[row.EditID] = struct{}{} + baselineRow, exists := baseline[row.EditID] + if !exists { + return 0, fmt.Errorf("challenger edit %q has no baseline", row.EditID) + } + result = maximum(result, regressionBasisPoints(baselineRow.ChangedLines, row.ChangedLines), regressionBasisPoints(baselineRow.ChangedBytes, row.ChangedBytes)) + } + return result, nil +} + +func regressionBasisPoints(baseline int, candidate int) int { + if baseline <= 0 || candidate <= baseline { + return 0 + } + return int(int64(candidate-baseline) * 10000 / int64(baseline)) +} + +func maximum(values ...int) int { + result := 0 + for _, value := range values { + if value > result { + result = value + } + } + return result +} + +func cloneBoolMap(value map[string]bool) map[string]bool { + result := make(map[string]bool, len(value)) + for key, item := range value { + result[key] = item + } + return result +} diff --git a/internal/kernel/requirementsourcecodec/selection_test.go b/internal/kernel/requirementsourcecodec/selection_test.go index b04aa5e..05e7d88 100644 --- a/internal/kernel/requirementsourcecodec/selection_test.go +++ b/internal/kernel/requirementsourcecodec/selection_test.go @@ -22,6 +22,7 @@ type codecSelection struct { SchemaVersion int `json:"schemaVersion"` Kind string `json:"kind"` ScreenEvidence screenEvidence `json:"screenEvidence"` + GrammarOwner grammarOwner `json:"grammarOwner"` Roles selectionRoles `json:"roles"` JSONLayoutOrder []string `json:"jsonLayoutOrder"` MetricRegistry []selectionMetric `json:"metricRegistry"` @@ -32,6 +33,13 @@ type codecSelection struct { NonClaims []string `json:"nonClaims"` } +type grammarOwner struct { + OwnerPackage string `json:"ownerPackage"` + DocumentKind string `json:"documentKind"` + SchemaVersion int `json:"schemaVersion"` + ProductionFiles []string `json:"productionFiles"` +} + type screenEvidence struct { ArchiveFormat string `json:"archiveFormat"` ArchivePath string `json:"archivePath"` @@ -196,40 +204,8 @@ func assertJSONLayoutOrder(t *testing.T, order []string) { func assertSortedUniqueMetricRegistry(t *testing.T, metrics []selectionMetric) { t.Helper() - ids := make([]string, len(metrics)) - for index, metric := range metrics { - ids[index] = metric.MetricID - if metric.MetricID == "" || metric.Stage == "" || metric.Role == "" || metric.Direction == "" || metric.Baseline == "" || metric.Aggregation == "" || metric.Requirement == "" || metric.Missing == "" || metric.MaterialThreshold < 0 { - t.Fatalf("incomplete metric row %#v", metric) - } - } - want := append([]string(nil), ids...) - sort.Strings(want) - if !reflect.DeepEqual(ids, want) { - t.Fatalf("metric IDs = %v, want sorted unique %v", ids, want) - } - for index := 1; index < len(ids); index++ { - if ids[index-1] == ids[index] { - t.Fatalf("duplicate metric %q", ids[index]) - } - } - wantIDs := []string{ - "aggregate_diff_regression_basis_points", "changed_bytes", "changed_lines", "edit_locality", "field_closure", "format_time_state", - "invalid_mutation_false_accepts", "lower_cost_dominance_state", "parse_time_state", "per_edit_diff_regression_basis_points", - "projected_production_branches", "projected_production_loc", "review_accuracy_basis_points", - "weighted_canonical_bytes", "weighted_tokens_o200k_base", - } - if !reflect.DeepEqual(ids, wantIDs) { - t.Fatalf("metric IDs = %v, want exact registry %v", ids, wantIDs) - } - for _, metric := range metrics { - if metric.Stage == "replacement" { - if metric.Role != "hard" || metric.Missing != "reject" { - t.Fatalf("replacement performance metric = %#v", metric) - } - } else if metric.Stage != "screen" { - t.Fatalf("screen metric %q has stage %q", metric.MetricID, metric.Stage) - } + if _, ok := admittedSelectionMetricRegistry(metrics); !ok { + t.Fatalf("metric registry = %#v, want exact admitted semantics %#v", metrics, selectionMetricSemantics) } } diff --git a/internal/kernel/requirementsourcecodec/testdata/codec-selection.v1.json b/internal/kernel/requirementsourcecodec/testdata/codec-selection.v1.json index 0177cc0..80a50b9 100644 --- a/internal/kernel/requirementsourcecodec/testdata/codec-selection.v1.json +++ b/internal/kernel/requirementsourcecodec/testdata/codec-selection.v1.json @@ -21,6 +21,12 @@ {"role": "token-python", "path": "out-v3/tokens-python.v3.json", "sha256": "e19d6985ad9599c86bbd672aef93dfed4e1643e48282e0e1ee91a388721e55db"} ] }, + "grammarOwner": { + "ownerPackage": "internal/kernel/requirementsourcecodec", + "documentKind": "proofkit.requirement-source", + "schemaVersion": 2, + "productionFiles": ["diagnostic_path.go", "document.go", "format.go", "json_index.go", "limits.go", "parse.go", "shape.go", "types.go"] + }, "roles": { "statusQuoComparators": ["flat-v1"], "modelAblations": [ @@ -81,9 +87,12 @@ "TestSelectionEvidenceIsByteBoundAndProjectsDecision", "TestScreenTreeDigestRejectsByteAndInventoryMutation", "TestScreenArchiveAdmissionRejectsUnsafeTopology", + "TestScreenArchiveAdmissionRejectsTrailingCompressedData", + "TestDecisionProjectionRejectsReplacementObservationDrift", "TestChallengerEligibilityRequiresEveryReplacementPredicate", + "TestSelectionMetricRegistrySemanticsDriveEvaluator", "TestProductionPackageHasOneCodecAndNoSyntaxDependency", - "TestRepositoryHasOneV2GrammarIdentityOwner", + "TestSelectedV2GrammarOwnerRecordIsExact", "TestFieldManifestMatchesWireDTOAndClosedShape", "TestFormatParseRoundTripPreservesEveryProjection", "TestCanonicalFormatIsIdempotent", @@ -123,6 +132,7 @@ "No restricted-text challenger reached the replacement stage; missing parse and format comparisons therefore reject replacement rather than proving performance parity.", "Screen-only YAML and TOML renderers are not admitted source codecs.", "The rejected restricted-text sketch is not a grammar or parser authority.", + "The admitted grammar-owner record and exact package inventory do not prove the open-world absence of undeclared semantically equivalent parsers.", "The selected private codec does not migrate current sources or alter the public CLI." ] } diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index 3e9e906..e3b3ef2 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -702,7 +702,7 @@ "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", "claimLevel": "blocking", "proofState": "witness_backed", - "nonClaims": ["This private codec does not migrate or rewrite current requirement sources, expose a public source extension or CLI command, retain a normalized mirror, authenticate requirement meaning or derivation provenance, generalize the frozen formatter screen beyond its byte-bound corpus, prevent a future owner-approved grammar from being added, execute native witnesses, approve merge or release, or establish rollout or production readiness."] + "nonClaims": ["This private codec does not migrate or rewrite current requirement sources, expose a public source extension or CLI command, retain a normalized mirror, authenticate requirement meaning or derivation provenance, generalize the frozen formatter screen beyond its byte-bound corpus, prove the open-world absence of undeclared semantically equivalent parsers, prevent a future owner-approved grammar from being added, execute native witnesses, approve merge or release, or establish rollout or production readiness."] }, { "requirementId": "REQ-PROOFKIT-WORKFLOW-001", @@ -4618,6 +4618,25 @@ { "selector": "TestScreenArchiveAdmissionRejectsUnsafeTopology", "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestScreenArchiveAdmissionRejectsUnsafeTopology$'" + }, + { + "selector": "TestScreenArchiveAdmissionRejectsTrailingCompressedData", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestScreenArchiveAdmissionRejectsTrailingCompressedData$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-025", + "scenarioId": "proofkit.spec-proof-core.requirement-source-codec-decision-projection-closure", + "witnessId": "proofkit.requirement-source-codec.decision-projection-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcecodec/selection_projection_test.go", + "witnessSelectors": [ + { + "selector": "TestDecisionProjectionRejectsReplacementObservationDrift", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestDecisionProjectionRejectsReplacementObservationDrift$'" } ], "commandIds": ["proofkit.go-test"], @@ -4633,6 +4652,10 @@ { "selector": "TestChallengerEligibilityRequiresEveryReplacementPredicate", "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestChallengerEligibilityRequiresEveryReplacementPredicate$'" + }, + { + "selector": "TestSelectionMetricRegistrySemanticsDriveEvaluator", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestSelectionMetricRegistrySemanticsDriveEvaluator$'" } ], "commandIds": ["proofkit.go-test"], @@ -4650,8 +4673,8 @@ "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestProductionPackageHasOneCodecAndNoSyntaxDependency$'" }, { - "selector": "TestRepositoryHasOneV2GrammarIdentityOwner", - "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestRepositoryHasOneV2GrammarIdentityOwner$'" + "selector": "TestSelectedV2GrammarOwnerRecordIsExact", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestSelectedV2GrammarOwnerRecordIsExact$'" } ], "commandIds": ["proofkit.go-test"], From 04819b404212bb05fbdcb1b3f51eb90369874963 Mon Sep 17 00:00:00 2001 From: iperev Date: Tue, 1 Sep 2026 22:08:40 +0200 Subject: [PATCH 4/5] fix: close codec metric projection --- .../selection_projection_test.go | 140 ++++++++++++++---- 1 file changed, 112 insertions(+), 28 deletions(-) diff --git a/internal/kernel/requirementsourcecodec/selection_projection_test.go b/internal/kernel/requirementsourcecodec/selection_projection_test.go index d64b0f4..c3b806f 100644 --- a/internal/kernel/requirementsourcecodec/selection_projection_test.go +++ b/internal/kernel/requirementsourcecodec/selection_projection_test.go @@ -5,6 +5,7 @@ import ( "fmt" "reflect" "sort" + "strings" "testing" ) @@ -86,8 +87,9 @@ func TestDecisionProjectionRejectsReplacementObservationDrift(t *testing.T) { } tests := []struct { - name string - mutate func(*codecSelection, *screenDecisionEvidence) + name string + wantError string + mutate func(*codecSelection, *screenDecisionEvidence) }{ {name: "aggregate diff", mutate: func(value *codecSelection, _ *screenDecisionEvidence) { mutateObservation(value, "proofkit-source-text-v1", func(item *screenObservation) { item.AggregateDiffRegressionBasisPoints = integerPointer(0) }) @@ -105,16 +107,76 @@ func TestDecisionProjectionRejectsReplacementObservationDrift(t *testing.T) { {name: "challenger predicate", mutate: func(_ *codecSelection, value *screenDecisionEvidence) { value.ChallengerPredicates.AggregateDiffNoninferior = true }}, + {name: "aggregate line regression", wantError: "screen challenger predicates", mutate: func(value *codecSelection, decision *screenDecisionEvidence) { + mutateObservation(value, "proofkit-source-text-v1", func(item *screenObservation) { + item.ChangedLines = 36 + item.ChangedBytes = 5721 + item.EditLocality = true + item.AggregateDiffRegressionBasisPoints = integerPointer(588) + item.PerEditDiffRegressionBasisPoints = integerPointer(833) + }) + baseline := decisionCandidate(decision, "json-hybrid-v1") + challenger := decisionCandidate(decision, "proofkit-source-text-v1") + challenger.EditRows = cloneDecisionEditRows(baseline.EditRows) + challenger.ChangedLines = 36 + challenger.ChangedBytes = 5721 + challenger.EditLocality = true + for index := range challenger.EditRows { + if challenger.EditRows[index].EditID == "merge" || challenger.EditRows[index].EditID == "split" { + challenger.EditRows[index].ChangedLines++ + } + } + decision.ChallengerPredicates.AggregateDiffNoninferior = true + decision.ChallengerPredicates.EditLocality = true + decision.ChallengerPredicates.PerEditDiffNoninferior = true + }}, + {name: "per-edit line regression", wantError: "screen challenger predicates", mutate: func(value *codecSelection, decision *screenDecisionEvidence) { + mutateObservation(value, "proofkit-source-text-v1", func(item *screenObservation) { + item.ChangedLines = 35 + item.ChangedBytes = 5721 + item.EditLocality = true + item.AggregateDiffRegressionBasisPoints = integerPointer(294) + item.PerEditDiffRegressionBasisPoints = integerPointer(10000) + }) + baseline := decisionCandidate(decision, "json-hybrid-v1") + challenger := decisionCandidate(decision, "proofkit-source-text-v1") + challenger.EditRows = cloneDecisionEditRows(baseline.EditRows) + challenger.ChangedLines = 35 + challenger.ChangedBytes = 5721 + challenger.EditLocality = true + for index := range challenger.EditRows { + if challenger.EditRows[index].EditID == "add" { + challenger.EditRows[index].ChangedLines++ + } + } + decision.ChallengerPredicates.AggregateDiffNoninferior = true + decision.ChallengerPredicates.EditLocality = true + decision.ChallengerPredicates.PerEditDiffNoninferior = true + }}, + {name: "production branch regression", wantError: "screen challenger predicates", mutate: func(value *codecSelection, decision *screenDecisionEvidence) { + mutateObservation(value, "proofkit-source-text-v1", func(item *screenObservation) { + item.ProjectedProductionLOC = 535 + item.ProjectedProductionBranches = 70 + }) + challenger := decisionCandidate(decision, "proofkit-source-text-v1") + challenger.ProjectedProduction.LOC = 535 + challenger.ProjectedProduction.Branches = 70 + decision.ChallengerPredicates.ProjectedProductionCost = true + }}, } for _, item := range tests { t.Run(item.name, func(t *testing.T) { mutatedRecord := readCodecSelection(t) mutatedRecord.ScreenObservations = append([]screenObservation(nil), mutatedRecord.ScreenObservations...) - mutatedDecision := decision + mutatedDecision := cloneScreenDecisionEvidence(decision) item.mutate(&mutatedRecord, &mutatedDecision) - if err := verifyDecisionProjection(mutatedRecord, mutatedDecision); err == nil { + err := verifyDecisionProjection(mutatedRecord, mutatedDecision) + if err == nil { t.Fatal("decision projection admitted a causal evidence mutation") } + if item.wantError != "" && !strings.Contains(err.Error(), item.wantError) { + t.Fatalf("decision projection error = %q, want %q", err, item.wantError) + } }) } } @@ -264,6 +326,10 @@ func frozenScreenChallengerPredicates(record codecSelection, candidates map[stri strictlyDominates = false } } + perEditRegression, err := maximumEditRegression(baseline.EditRows, challenger.EditRows) + if err != nil { + return screenChallengerPredicates{}, err + } return screenChallengerPredicates{ GroupedJSONAccepted: record.Decision.SelectedJSONLayout != "", ReviewPresent: challenger.Review != nil, @@ -272,9 +338,9 @@ func frozenScreenChallengerPredicates(record codecSelection, candidates map[stri EditLocality: challenger.EditLocality, ByteImprovement: materiallyBetter(baseline.WeightedCanonicalBytes, challenger.WeightedCanonicalBytes, record.ReplacementPolicy.MinimumByteImprovementBasisPoints), TokenImprovement: materiallyBetter(baseline.WeightedTokensO200kBase, challenger.WeightedTokensO200kBase, record.ReplacementPolicy.MinimumTokenImprovementBasisPoints), - AggregateDiffNoninferior: regressionBasisPoints(baseline.ChangedBytes, challenger.ChangedBytes) <= record.ReplacementPolicy.MaximumAggregateDiffRegressionBasisPoints, - PerEditDiffNoninferior: perEditBytesNoninferior(baseline.EditRows, challenger.EditRows, record.ReplacementPolicy.MaximumPerEditDiffRegressionBasisPoints), - ProjectedProductionCost: regressionBasisPoints(baseline.ProjectedProduction.LOC, challenger.ProjectedProduction.LOC) <= record.ReplacementPolicy.MaximumProjectedProductionCostBasisPoints-10000, + AggregateDiffNoninferior: aggregateDiffRegression(baseline, challenger) <= record.ReplacementPolicy.MaximumAggregateDiffRegressionBasisPoints, + PerEditDiffNoninferior: perEditRegression <= record.ReplacementPolicy.MaximumPerEditDiffRegressionBasisPoints, + ProjectedProductionCost: projectedProductionWithinPolicy(baseline, challenger, record.ReplacementPolicy.MaximumProjectedProductionCostBasisPoints), LowerCostComparisonComplete: lowerCostComplete, StrictlyDominatesLowerCostComparators: lowerCostComplete && strictlyDominates, }, nil @@ -295,23 +361,6 @@ func dominatesEveryPrimaryMetric(challenger screenDecisionCandidate, candidate s return nonWorse && strict } -func perEditBytesNoninferior(baselineRows []screenDecisionEditRow, challengerRows []screenDecisionEditRow, maximum int) bool { - baseline := make(map[string]screenDecisionEditRow, len(baselineRows)) - for _, row := range baselineRows { - baseline[row.EditID] = row - } - if len(baseline) != len(challengerRows) { - return false - } - for _, row := range challengerRows { - baselineRow, exists := baseline[row.EditID] - if !exists || regressionBasisPoints(baselineRow.ChangedBytes, row.ChangedBytes) > maximum { - return false - } - } - return true -} - func verifyReplacementProjection(record codecSelection, candidates map[string]screenDecisionCandidate, predicates screenChallengerPredicates) error { if len(record.Roles.RestrictedTextChallengers) != 1 { return errors.New("selection must have exactly one restricted-text challenger") @@ -322,10 +371,7 @@ func verifyReplacementProjection(record codecSelection, candidates map[string]sc if !baselineExists || !challengerExists || !observationExists { return errors.New("replacement projection lacks baseline or challenger") } - aggregate := maximum( - regressionBasisPoints(baseline.ChangedLines, challenger.ChangedLines), - regressionBasisPoints(baseline.ChangedBytes, challenger.ChangedBytes), - ) + aggregate := aggregateDiffRegression(baseline, challenger) perEdit, err := maximumEditRegression(baseline.EditRows, challenger.EditRows) if err != nil { return err @@ -347,6 +393,18 @@ func verifyReplacementProjection(record codecSelection, candidates map[string]sc return nil } +func aggregateDiffRegression(baseline screenDecisionCandidate, challenger screenDecisionCandidate) int { + return maximum( + regressionBasisPoints(baseline.ChangedLines, challenger.ChangedLines), + regressionBasisPoints(baseline.ChangedBytes, challenger.ChangedBytes), + ) +} + +func projectedProductionWithinPolicy(baseline screenDecisionCandidate, challenger screenDecisionCandidate, maximumBasisPoints int) bool { + return withinRatio(baseline.ProjectedProduction.LOC, challenger.ProjectedProduction.LOC, maximumBasisPoints) && + withinRatio(baseline.ProjectedProduction.Branches, challenger.ProjectedProduction.Branches, maximumBasisPoints) +} + func maximumEditRegression(baselineRows []screenDecisionEditRow, challengerRows []screenDecisionEditRow) (int, error) { baseline := make(map[string]screenDecisionEditRow, len(baselineRows)) for _, row := range baselineRows { @@ -398,3 +456,29 @@ func cloneBoolMap(value map[string]bool) map[string]bool { } return result } + +func cloneScreenDecisionEvidence(value screenDecisionEvidence) screenDecisionEvidence { + result := value + result.Candidates = append([]screenDecisionCandidate(nil), value.Candidates...) + for index := range result.Candidates { + result.Candidates[index].EditRows = cloneDecisionEditRows(value.Candidates[index].EditRows) + } + return result +} + +func cloneDecisionEditRows(value []screenDecisionEditRow) []screenDecisionEditRow { + result := append([]screenDecisionEditRow(nil), value...) + for index := range result { + result[index].LocalityViolations = append([]string(nil), value[index].LocalityViolations...) + } + return result +} + +func decisionCandidate(value *screenDecisionEvidence, candidateID string) *screenDecisionCandidate { + for index := range value.Candidates { + if value.Candidates[index].CandidateID == candidateID { + return &value.Candidates[index] + } + } + panic("missing frozen decision candidate") +} From c45c7a8f2f217f1a6441107d6f3b1e9d5f0e97c7 Mon Sep 17 00:00:00 2001 From: iperev Date: Tue, 1 Sep 2026 22:23:42 +0200 Subject: [PATCH 5/5] test: close codec metric mutation basis --- .../selection_projection_test.go | 162 ++++++++++++------ 1 file changed, 110 insertions(+), 52 deletions(-) diff --git a/internal/kernel/requirementsourcecodec/selection_projection_test.go b/internal/kernel/requirementsourcecodec/selection_projection_test.go index c3b806f..f283b19 100644 --- a/internal/kernel/requirementsourcecodec/selection_projection_test.go +++ b/internal/kernel/requirementsourcecodec/selection_projection_test.go @@ -87,9 +87,10 @@ func TestDecisionProjectionRejectsReplacementObservationDrift(t *testing.T) { } tests := []struct { - name string - wantError string - mutate func(*codecSelection, *screenDecisionEvidence) + name string + wantError string + wantPredicateDiff string + mutate func(*codecSelection, *screenDecisionEvidence) }{ {name: "aggregate diff", mutate: func(value *codecSelection, _ *screenDecisionEvidence) { mutateObservation(value, "proofkit-source-text-v1", func(item *screenObservation) { item.AggregateDiffRegressionBasisPoints = integerPointer(0) }) @@ -107,61 +108,50 @@ func TestDecisionProjectionRejectsReplacementObservationDrift(t *testing.T) { {name: "challenger predicate", mutate: func(_ *codecSelection, value *screenDecisionEvidence) { value.ChallengerPredicates.AggregateDiffNoninferior = true }}, - {name: "aggregate line regression", wantError: "screen challenger predicates", mutate: func(value *codecSelection, decision *screenDecisionEvidence) { - mutateObservation(value, "proofkit-source-text-v1", func(item *screenObservation) { - item.ChangedLines = 36 - item.ChangedBytes = 5721 - item.EditLocality = true - item.AggregateDiffRegressionBasisPoints = integerPointer(588) - item.PerEditDiffRegressionBasisPoints = integerPointer(833) - }) - baseline := decisionCandidate(decision, "json-hybrid-v1") - challenger := decisionCandidate(decision, "proofkit-source-text-v1") - challenger.EditRows = cloneDecisionEditRows(baseline.EditRows) - challenger.ChangedLines = 36 - challenger.ChangedBytes = 5721 - challenger.EditLocality = true - for index := range challenger.EditRows { - if challenger.EditRows[index].EditID == "merge" || challenger.EditRows[index].EditID == "split" { - challenger.EditRows[index].ChangedLines++ + {name: "aggregate line regression", wantError: "screen challenger predicates", wantPredicateDiff: "aggregateDiffNoninferior", mutate: func(value *codecSelection, decision *screenDecisionEvidence) { + mutateDecisionDiffProjection(value, decision, 36, 5721, 588, 833, func(rows []screenDecisionEditRow) { + for index := range rows { + if rows[index].EditID == "merge" || rows[index].EditID == "split" { + rows[index].ChangedLines++ + } } - } - decision.ChallengerPredicates.AggregateDiffNoninferior = true - decision.ChallengerPredicates.EditLocality = true - decision.ChallengerPredicates.PerEditDiffNoninferior = true + }) }}, - {name: "per-edit line regression", wantError: "screen challenger predicates", mutate: func(value *codecSelection, decision *screenDecisionEvidence) { - mutateObservation(value, "proofkit-source-text-v1", func(item *screenObservation) { - item.ChangedLines = 35 - item.ChangedBytes = 5721 - item.EditLocality = true - item.AggregateDiffRegressionBasisPoints = integerPointer(294) - item.PerEditDiffRegressionBasisPoints = integerPointer(10000) + {name: "aggregate byte regression", wantError: "screen challenger predicates", wantPredicateDiff: "aggregateDiffNoninferior", mutate: func(value *codecSelection, decision *screenDecisionEvidence) { + mutateDecisionDiffProjection(value, decision, 34, 6008, 501, 850, func(rows []screenDecisionEditRow) { + for index := range rows { + switch rows[index].EditID { + case "merge", "split": + rows[index].ChangedBytes += 100 + case "supersede": + rows[index].ChangedBytes += 87 + } + } }) - baseline := decisionCandidate(decision, "json-hybrid-v1") - challenger := decisionCandidate(decision, "proofkit-source-text-v1") - challenger.EditRows = cloneDecisionEditRows(baseline.EditRows) - challenger.ChangedLines = 35 - challenger.ChangedBytes = 5721 - challenger.EditLocality = true - for index := range challenger.EditRows { - if challenger.EditRows[index].EditID == "add" { - challenger.EditRows[index].ChangedLines++ + }}, + {name: "per-edit line regression", wantError: "screen challenger predicates", wantPredicateDiff: "perEditDiffNoninferior", mutate: func(value *codecSelection, decision *screenDecisionEvidence) { + mutateDecisionDiffProjection(value, decision, 35, 5721, 294, 10000, func(rows []screenDecisionEditRow) { + for index := range rows { + if rows[index].EditID == "add" { + rows[index].ChangedLines++ + } } - } - decision.ChallengerPredicates.AggregateDiffNoninferior = true - decision.ChallengerPredicates.EditLocality = true - decision.ChallengerPredicates.PerEditDiffNoninferior = true + }) }}, - {name: "production branch regression", wantError: "screen challenger predicates", mutate: func(value *codecSelection, decision *screenDecisionEvidence) { - mutateObservation(value, "proofkit-source-text-v1", func(item *screenObservation) { - item.ProjectedProductionLOC = 535 - item.ProjectedProductionBranches = 70 + {name: "per-edit byte regression", wantError: "screen challenger predicates", wantPredicateDiff: "perEditDiffNoninferior", mutate: func(value *codecSelection, decision *screenDecisionEvidence) { + mutateDecisionDiffProjection(value, decision, 34, 5756, 61, 1535, func(rows []screenDecisionEditRow) { + for index := range rows { + if rows[index].EditID == "add" { + rows[index].ChangedBytes += 35 + } + } }) - challenger := decisionCandidate(decision, "proofkit-source-text-v1") - challenger.ProjectedProduction.LOC = 535 - challenger.ProjectedProduction.Branches = 70 - decision.ChallengerPredicates.ProjectedProductionCost = true + }}, + {name: "production LOC regression", wantError: "screen challenger predicates", wantPredicateDiff: "projectedProductionCost", mutate: func(value *codecSelection, decision *screenDecisionEvidence) { + mutateDecisionProductionProjection(value, decision, 803, 46) + }}, + {name: "production branch regression", wantError: "screen challenger predicates", wantPredicateDiff: "projectedProductionCost", mutate: func(value *codecSelection, decision *screenDecisionEvidence) { + mutateDecisionProductionProjection(value, decision, 535, 70) }}, } for _, item := range tests { @@ -170,6 +160,22 @@ func TestDecisionProjectionRejectsReplacementObservationDrift(t *testing.T) { mutatedRecord.ScreenObservations = append([]screenObservation(nil), mutatedRecord.ScreenObservations...) mutatedDecision := cloneScreenDecisionEvidence(decision) item.mutate(&mutatedRecord, &mutatedDecision) + if item.wantPredicateDiff != "" { + candidates := make(map[string]screenDecisionCandidate, len(mutatedDecision.Candidates)) + for _, candidate := range mutatedDecision.Candidates { + candidates[candidate.CandidateID] = candidate + } + wantPredicates, predicateErr := frozenScreenChallengerPredicates(mutatedRecord, candidates) + if predicateErr != nil { + t.Fatalf("causal mutation predicate precondition: %v", predicateErr) + } + if projectionErr := verifyReplacementProjection(mutatedRecord, candidates, wantPredicates); projectionErr != nil { + t.Fatalf("causal mutation is not observation-closed: %v", projectionErr) + } + if diffs := challengerPredicateDiffs(mutatedDecision.ChallengerPredicates, wantPredicates); !reflect.DeepEqual(diffs, []string{item.wantPredicateDiff}) { + t.Fatalf("causal mutation predicate diffs = %v, want [%s]", diffs, item.wantPredicateDiff) + } + } err := verifyDecisionProjection(mutatedRecord, mutatedDecision) if err == nil { t.Fatal("decision projection admitted a causal evidence mutation") @@ -405,6 +411,19 @@ func projectedProductionWithinPolicy(baseline screenDecisionCandidate, challenge withinRatio(baseline.ProjectedProduction.Branches, challenger.ProjectedProduction.Branches, maximumBasisPoints) } +func challengerPredicateDiffs(left screenChallengerPredicates, right screenChallengerPredicates) []string { + leftValue := reflect.ValueOf(left) + rightValue := reflect.ValueOf(right) + typeOfValue := leftValue.Type() + diffs := make([]string, 0, typeOfValue.NumField()) + for index := 0; index < typeOfValue.NumField(); index++ { + if leftValue.Field(index).Bool() != rightValue.Field(index).Bool() { + diffs = append(diffs, typeOfValue.Field(index).Tag.Get("json")) + } + } + return diffs +} + func maximumEditRegression(baselineRows []screenDecisionEditRow, challengerRows []screenDecisionEditRow) (int, error) { baseline := make(map[string]screenDecisionEditRow, len(baselineRows)) for _, row := range baselineRows { @@ -457,6 +476,45 @@ func cloneBoolMap(value map[string]bool) map[string]bool { return result } +func mutateDecisionDiffProjection( + record *codecSelection, + decision *screenDecisionEvidence, + changedLines int, + changedBytes int, + aggregateRegression int, + perEditRegression int, + mutateRows func([]screenDecisionEditRow), +) { + mutateObservation(record, "proofkit-source-text-v1", func(item *screenObservation) { + item.ChangedLines = changedLines + item.ChangedBytes = changedBytes + item.EditLocality = true + item.AggregateDiffRegressionBasisPoints = integerPointer(aggregateRegression) + item.PerEditDiffRegressionBasisPoints = integerPointer(perEditRegression) + }) + baseline := decisionCandidate(decision, "json-hybrid-v1") + challenger := decisionCandidate(decision, "proofkit-source-text-v1") + challenger.EditRows = cloneDecisionEditRows(baseline.EditRows) + mutateRows(challenger.EditRows) + challenger.ChangedLines = changedLines + challenger.ChangedBytes = changedBytes + challenger.EditLocality = true + decision.ChallengerPredicates.AggregateDiffNoninferior = true + decision.ChallengerPredicates.EditLocality = true + decision.ChallengerPredicates.PerEditDiffNoninferior = true +} + +func mutateDecisionProductionProjection(record *codecSelection, decision *screenDecisionEvidence, loc int, branches int) { + mutateObservation(record, "proofkit-source-text-v1", func(item *screenObservation) { + item.ProjectedProductionLOC = loc + item.ProjectedProductionBranches = branches + }) + challenger := decisionCandidate(decision, "proofkit-source-text-v1") + challenger.ProjectedProduction.LOC = loc + challenger.ProjectedProduction.Branches = branches + decision.ChallengerPredicates.ProjectedProductionCost = true +} + func cloneScreenDecisionEvidence(value screenDecisionEvidence) screenDecisionEvidence { result := value result.Candidates = append([]screenDecisionCandidate(nil), value.Candidates...)