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/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..8793330 100644 --- a/docs/specs/proofkit-spec-proof-core/overview.md +++ b/docs/specs/proofkit-spec-proof-core/overview.md @@ -154,6 +154,20 @@ 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 + 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 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 + 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 420552c..1f4f566 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 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, 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} } ], "nonClaims": [ 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 new file mode 100644 index 0000000..0285b26 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/admission_test.go @@ -0,0 +1,201 @@ +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 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{ + {}, + []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/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 new file mode 100644 index 0000000..b113fa4 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/diagnostics_test.go @@ -0,0 +1,101 @@ +package requirementsourcecodec + +import ( + "bytes" + "strings" + "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 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) + 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..d16475d --- /dev/null +++ b/internal/kernel/requirementsourcecodec/field_manifest_test.go @@ -0,0 +1,234 @@ +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"` + LimitOwner string `json:"limitOwner,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, LimitOwner: manifestLimitOwner(recordType.Name(), name)} + 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, field.LimitOwner, 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, limitOwner string, actual *shape, records map[string]manifestRecord, seen map[string]struct{}) { + t.Helper() + switch { + case expected == "string" && actual.kind == shapeString && limitOwner == "" && actual.limitOwner == "": + return + case expected == "boolean" && actual.kind == shapeBoolean && limitOwner == "" && actual.limitOwner == "": + return + 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: + assertShapeLimitOwner(t, limitOwner, actual) + assertShapeType(t, strings.TrimPrefix(expected, "array:"), "", actual.element, records, seen) + return + 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/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..7c34dc0 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/json_index.go @@ -0,0 +1,361 @@ +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 + locations map[string]rawLocation +} + +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, expected) + if err != nil { + return indexedValue{}, err + } + before := decoder.InputOffset() + _, err = decoder.Token() + if err == nil { + 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()) + } + return indexedValue{value: value, locations: indexer.locations}, nil +} + +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", safePath, ByteSpan{Start: offset, End: offset}, true) + } + token, span, err := indexer.nextToken(safePath) + if err != nil { + return nil, ByteSpan{}, err + } + delimiter, isDelimiter := token.(json.Delim) + if !isDelimiter { + indexer.locations[rawPath] = rawLocation{value: span} + return token, span, nil + } + switch delimiter { + case '{': + return indexer.parseObject(rawPath, safePath, depth, span, expected) + case '[': + return indexer.parseArray(rawPath, safePath, depth, span, expected) + default: + return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_syntax", safePath, span, true) + } +} + +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(safePath) + if err != nil { + return nil, ByteSpan{}, err + } + key, ok := keyToken.(string) + if !ok { + 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", safePath, keySpan, true) + } + seen[key] = struct{}{} + 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[rawChildPath] + location.key = &keySpan + indexer.locations[rawChildPath] = location + result[key] = value + } + 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", safePath, closingSpan, true) + } + span := ByteSpan{Start: opening.Start, End: closingSpan.End} + location := indexer.locations[rawPath] + location.value = span + indexer.locations[rawPath] = location + return result, span, nil +} + +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++ { + 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(safePath) + if err != nil { + return nil, ByteSpan{}, err + } + if closingToken != json.Delim(']') { + return nil, ByteSpan{}, diagnosticError(indexer.source, "invalid_syntax", safePath, closingSpan, true) + } + span := ByteSpan{Start: opening.Start, End: closingSpan.End} + location := indexer.locations[rawPath] + location.value = span + indexer.locations[rawPath] = 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() + span := tokenSpan(indexer.source, before, after) + 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 + } + 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 + previousCR := false + 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 == '\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} + 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..cdd7596 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/limits_test.go @@ -0,0 +1,163 @@ +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 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) + 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..48e5431 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/package_boundary_test.go @@ -0,0 +1,116 @@ +package requirementsourcecodec + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "reflect" + "sort" + "strconv" + "strings" + "testing" +) + +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": {}, + } + 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 := record.GrammarOwner.ProductionFiles + 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) + } +} + +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) + } + 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/parse.go b/internal/kernel/requirementsourcecodec/parse.go new file mode 100644 index 0000000..c39f9ad --- /dev/null +++ b/internal/kernel/requirementsourcecodec/parse.go @@ -0,0 +1,80 @@ +package requirementsourcecodec + +import ( + "bytes" + "encoding/json" + "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) + } + if err := preflightTokenLimit(source, codecLimits.MaxTokens); err != nil { + return Result{}, err + } + + wireShape := documentShape(modelLimits) + indexed, err := indexJSON(source, codecLimits, wireShape) + if err != nil { + return Result{}, err + } + if err := validateShape(indexed.value, wireShape, "", 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, wire, 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, 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) + } + 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 new file mode 100644 index 0000000..78ef970 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/roundtrip_test.go @@ -0,0 +1,124 @@ +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") + } +} + +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 new file mode 100644 index 0000000..5ba9c0d --- /dev/null +++ b/internal/kernel/requirementsourcecodec/selection_evaluator_test.go @@ -0,0 +1,362 @@ +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 { + eligibility, eligibilityOK := jsonLayoutEligibility(record) + if !eligibilityOK { + return "" + } + eligibleRows := []screenObservation{} + for _, observation := range record.ScreenObservations { + if eligibility[observation.CandidateID] { + eligibleRows = append(eligibleRows, observation) + } + } + sort.Slice(eligibleRows, func(left, right int) bool { + for _, metricID := range record.JSONLayoutOrder { + if metricID == "candidate_id" { + return eligibleRows[left].CandidateID < eligibleRows[right].CandidateID + } + leftValue := layoutMetricValue(eligibleRows[left], metricID) + rightValue := layoutMetricValue(eligibleRows[right], metricID) + if leftValue != rightValue { + return leftValue < rightValue + } + } + return false + }) + if len(eligibleRows) == 0 { + return "" + } + 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 + } + baseline, baselineExists := observationByID(record.ScreenObservations, selectedJSON) + challenger, challengerExists := observationByID(record.ScreenObservations, record.Roles.RestrictedTextChallengers[0]) + if !baselineExists || !challengerExists { + return false + } + policy := record.ReplacementPolicy + return challengerPassesScreenMetrics(challenger, policy, metrics) && + materiallyBetter(baseline.WeightedCanonicalBytes, challenger.WeightedCanonicalBytes, policy.MinimumByteImprovementBasisPoints) && + materiallyBetter(baseline.WeightedTokensO200kBase, challenger.WeightedTokensO200kBase, policy.MinimumTokenImprovementBasisPoints) && + 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 { + 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 { + 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 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" })}, + {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 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) + 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.AggregateDiffRegressionBasisPoints = integerPointer(0) + item.PerEditDiffRegressionBasisPoints = integerPointer(0) + 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_evidence_test.go b/internal/kernel/requirementsourcecodec/selection_evidence_test.go new file mode 100644 index 0000000..b459256 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/selection_evidence_test.go @@ -0,0 +1,410 @@ +package requirementsourcecodec + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "reflect" + "sort" + "strings" + "testing" + "testing/fstest" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" +) + +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"` +} + +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 := readStrictEvidence[screenDecisionEvidence](t, root, artifacts["screen-decision"].Path) + assertDecisionLinks(t, decision, artifacts) + if err := verifyDecisionProjection(record, decision); err != nil { + t.Fatal(err) + } +} + +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 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)) + 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) + } + 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": wantTokenReports["openai-tiktoken-0.14.0"], + "token-go": wantTokenReports["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 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 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) + 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) { + remaining := bytes.NewReader(payload) + compressed, err := gzip.NewReader(remaining) + 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} + } + 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 +} + +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_projection_test.go b/internal/kernel/requirementsourcecodec/selection_projection_test.go new file mode 100644 index 0000000..f283b19 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/selection_projection_test.go @@ -0,0 +1,542 @@ +package requirementsourcecodec + +import ( + "errors" + "fmt" + "reflect" + "sort" + "strings" + "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 + 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) }) + }}, + {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 + }}, + {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++ + } + } + }) + }}, + {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 + } + } + }) + }}, + {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++ + } + } + }) + }}, + {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 + } + } + }) + }}, + {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 { + t.Run(item.name, func(t *testing.T) { + mutatedRecord := readCodecSelection(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") + } + if item.wantError != "" && !strings.Contains(err.Error(), item.wantError) { + t.Fatalf("decision projection error = %q, want %q", err, item.wantError) + } + }) + } +} + +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 + } + } + perEditRegression, err := maximumEditRegression(baseline.EditRows, challenger.EditRows) + if err != nil { + return screenChallengerPredicates{}, err + } + 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: aggregateDiffRegression(baseline, challenger) <= record.ReplacementPolicy.MaximumAggregateDiffRegressionBasisPoints, + PerEditDiffNoninferior: perEditRegression <= record.ReplacementPolicy.MaximumPerEditDiffRegressionBasisPoints, + ProjectedProductionCost: projectedProductionWithinPolicy(baseline, challenger, record.ReplacementPolicy.MaximumProjectedProductionCostBasisPoints), + 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 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 := aggregateDiffRegression(baseline, challenger) + 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 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 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 { + 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 +} + +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...) + 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") +} diff --git a/internal/kernel/requirementsourcecodec/selection_test.go b/internal/kernel/requirementsourcecodec/selection_test.go new file mode 100644 index 0000000..05e7d88 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/selection_test.go @@ -0,0 +1,404 @@ +package requirementsourcecodec + +import ( + "bytes" + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "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"` + GrammarOwner grammarOwner `json:"grammarOwner"` + Roles selectionRoles `json:"roles"` + JSONLayoutOrder []string `json:"jsonLayoutOrder"` + 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 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"` + 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 { + 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"` + AggregateDiffRegressionBasisPoints *int `json:"aggregateDiffRegressionBasisPoints"` + PerEditDiffRegressionBasisPoints *int `json:"perEditDiffRegressionBasisPoints"` + 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" { + t.Fatalf("selection identity = %#v", record) + } + assertScreenEvidenceMetadata(t, record.ScreenEvidence) + assertJSONLayoutOrder(t, record.JSONLayoutOrder) + 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 assertScreenEvidenceMetadata(t *testing.T, value screenEvidence) { + t.Helper() + 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) + } + if _, err := admit.LowercaseSHA256(value.ArchiveSHA256, "archiveSha256"); err != nil { + t.Fatalf("invalid screen archive digest %q", value.ArchiveSHA256) + } + 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) + } + } + if !reflect.DeepEqual(roles, wantRoles) { + t.Fatalf("screen artifact roles = %v, want %v", roles, wantRoles) + } +} + +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) + } +} + +func assertSortedUniqueMetricRegistry(t *testing.T, metrics []selectionMetric) { + t.Helper() + if _, ok := admittedSelectionMetricRegistry(metrics); !ok { + t.Fatalf("metric registry = %#v, want exact admitted semantics %#v", metrics, selectionMetricSemantics) + } +} + +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.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) { + t.Fatalf("screen observations = %v, want %v", actual, want) + } +} + +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 { + result[value] = struct{}{} + } + return result +} + +func assertHardGateSelectorsExact(t *testing.T, selectors []string) { + t.Helper() + 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) + } +} + +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..a9c5dc6 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/shape.go @@ -0,0 +1,306 @@ +package requirementsourcecodec + +import ( + "encoding/json" + "sort" + "strconv" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/requirementsourcemodel" +) + +type shapeKind uint8 + +type collectionLimitID string + +const ( + shapeObject shapeKind = iota + 1 + shapeArray + shapeString + shapeInteger + 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 +} + +type shape struct { + kind shapeKind + fields map[string]shapeField + dynamic *shape + element *shape + nullable bool + maxItems int + limitOwner collectionLimitID + exactString string + exactInt *int64 +} + +func documentShape(limits requirementsourcemodel.Limits) *shape { + stringValue := scalar(shapeString) + booleanValue := scalar(shapeBoolean) + integerValue := scalar(shapeInteger) + stringsValue := array(stringValue, limitCollectionItems, limits) + 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, limitMembersPerGroup, limits), + })) + 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, limitExamplesPerScenario, limits), + "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, 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), + })) +} + +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, 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 { + 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..ba02a3e --- /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, "limitOwner": "max_collection_items"} + ] + }, + { + "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, "limitOwner": "max_collection_items"}, + {"name": "nonClaimRefs", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"} + ] + }, + { + "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, "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, "limitOwner": "max_collection_items"} + ] + }, + { + "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, "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, "limitOwner": "max_collection_items"}, + {"name": "evidenceRefs", "type": "array:string", "required": true, "nullable": false, "limitOwner": "max_collection_items"} + ] + }, + { + "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, "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} + ] + }, + { + "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, "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"} + ] + }, + { + "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..80a50b9 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/testdata/codec-selection.v1.json @@ -0,0 +1,138 @@ +{ + "schemaVersion": 1, + "kind": "proofkit.requirement-source-codec-selection", + "screenEvidence": { + "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"} + ] + }, + "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": [ + "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"] + }, + "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_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} + ], + "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, "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", + "selectedJsonLayout": "json-hybrid-v1", + "selectedChallenger": null, + "selectedCodec": "grouped-json-v1", + "productionGrammarCount": 1 + }, + "hardGateSelectors": [ + "TestSelectionRecordIsClosedAndDecisionIsReproducible", + "TestSelectionEvidenceIsByteBoundAndProjectsDecision", + "TestScreenTreeDigestRejectsByteAndInventoryMutation", + "TestScreenArchiveAdmissionRejectsUnsafeTopology", + "TestScreenArchiveAdmissionRejectsTrailingCompressedData", + "TestDecisionProjectionRejectsReplacementObservationDrift", + "TestChallengerEligibilityRequiresEveryReplacementPredicate", + "TestSelectionMetricRegistrySemanticsDriveEvaluator", + "TestProductionPackageHasOneCodecAndNoSyntaxDependency", + "TestSelectedV2GrammarOwnerRecordIsExact", + "TestFieldManifestMatchesWireDTOAndClosedShape", + "TestFormatParseRoundTripPreservesEveryProjection", + "TestCanonicalFormatIsIdempotent", + "TestFormatPreservesMetadataAbsenceNullAndRecord", + "TestSourceMapReplaysKeyAndValueSpans", + "TestReturnedSourceMapIsImmutable", + "TestSourceMapIndexesLexicalWireOrderNotNormalizedOrder", + "TestHybridLayoutKeepsStableSiblingEntityLinesUnchanged", + "TestHybridLayoutUsesCommaFirstEntityArrays", + "TestCanonicalStringsEscapeUnsafeScalarsWithoutHTMLEscaping", + "TestCanonicalMapKeysAreSorted", + "TestOrderedActionsRetainOrder", + "TestCodecMutantManifestClosesRepresentationFailures", + "TestRawByteBoundaryIsExactAndDominatesUTF8", + "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.", + "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 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/internal/kernel/requirementsourcecodec/testdata/screen-v3.tgz b/internal/kernel/requirementsourcecodec/testdata/screen-v3.tgz new file mode 100644 index 0000000..5142f16 Binary files /dev/null and b/internal/kernel/requirementsourcecodec/testdata/screen-v3.tgz differ diff --git a/internal/kernel/requirementsourcecodec/types.go b/internal/kernel/requirementsourcecodec/types.go new file mode 100644 index 0000000..e1159e0 --- /dev/null +++ b/internal/kernel/requirementsourcecodec/types.go @@ -0,0 +1,114 @@ +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 +} + +// 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 +} + +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/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 417120f..e3b3ef2 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 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", "ownerId": "proofkit.agent-workflow", @@ -4577,6 +4585,328 @@ "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-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$'" + }, + { + "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"], + "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$'" + }, + { + "selector": "TestSelectionMetricRegistrySemanticsDriveEvaluator", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestSelectionMetricRegistrySemanticsDriveEvaluator$'" + } + ], + "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$'" + }, + { + "selector": "TestSelectedV2GrammarOwnerRecordIsExact", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestSelectedV2GrammarOwnerRecordIsExact$'" + } + ], + "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$'" + }, + { + "selector": "TestSourceMapIndexesLexicalWireOrderNotNormalizedOrder", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestSourceMapIndexesLexicalWireOrderNotNormalizedOrder$'" + } + ], + "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": "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"], + "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": "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"], + "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$'" + }, + { + "selector": "TestBareCRAndCRLFAdvanceScalarLinesOnce", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestBareCRAndCRLFAdvanceScalarLinesOnce$'" + }, + { + "selector": "TestMultipleValueDiagnosticSpansSecondToken", + "command": "go test ./internal/kernel/requirementsourcecodec -run '^TestMultipleValueDiagnosticSpansSecondToken$'" + } + ], + "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": "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$'" + } + ], + "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", 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": {