From 85e35a80a6ef63fc20bead658302213083950e84 Mon Sep 17 00:00:00 2001 From: iperev Date: Wed, 12 Aug 2026 04:30:17 +0200 Subject: [PATCH] feat: add typed requirement source model --- BACKLOG.md | 3 +- .../proofkit-spec-proof-core/overview.md | 8 + .../requirements.v1.json | 13 + .../accessor_immutability_test.go | 155 ++++++ .../kernel/requirementsourcemodel/clone.go | 182 +++++++ .../completeness_test.go | 385 ++++++++++++++ .../draft_observation_test.go | 314 +++++++++++ .../field_inventory_test.go | 315 +++++++++++ .../group_normalization.go | 160 ++++++ .../input_budget_test.go | 373 +++++++++++++ .../kernel/requirementsourcemodel/limits.go | 302 +++++++++++ .../kernel/requirementsourcemodel/metadata.go | 224 ++++++++ .../metadata_ownership_test.go | 258 +++++++++ .../requirementsourcemodel/model_test.go | 465 +++++++++++++++++ .../mutant_relation_negative_test.go | 107 ++++ .../mutant_relations_test.go | 318 ++++++++++++ .../requirementsourcemodel/mutants_test.go | 154 ++++++ .../requirementsourcemodel/normalize.go | 251 +++++++++ .../package_boundary_test.go | 67 +++ .../projection_budget.go | 286 ++++++++++ .../projection_budget_test.go | 100 ++++ .../projection_observation_test.go | 450 ++++++++++++++++ .../reference_closure.go | 115 +++++ .../scenario_evaluation.go | 114 ++++ .../scenario_evaluation_test.go | 88 ++++ .../scenario_normalization.go | 201 ++++++++ .../field-projection-manifest.v1.json | 85 +++ .../testdata/model-mutants.v1.json | 65 +++ .../kernel/requirementsourcemodel/types.go | 297 +++++++++++ .../requirementsourcemodel/validation.go | 187 +++++++ .../variant_inventory_test.go | 91 ++++ .../requirementsourcemodel/variants_test.go | 488 ++++++++++++++++++ proofkit/requirement-bindings.json | 299 +++++++++++ 33 files changed, 6918 insertions(+), 2 deletions(-) create mode 100644 internal/kernel/requirementsourcemodel/accessor_immutability_test.go create mode 100644 internal/kernel/requirementsourcemodel/clone.go create mode 100644 internal/kernel/requirementsourcemodel/completeness_test.go create mode 100644 internal/kernel/requirementsourcemodel/draft_observation_test.go create mode 100644 internal/kernel/requirementsourcemodel/field_inventory_test.go create mode 100644 internal/kernel/requirementsourcemodel/group_normalization.go create mode 100644 internal/kernel/requirementsourcemodel/input_budget_test.go create mode 100644 internal/kernel/requirementsourcemodel/limits.go create mode 100644 internal/kernel/requirementsourcemodel/metadata.go create mode 100644 internal/kernel/requirementsourcemodel/metadata_ownership_test.go create mode 100644 internal/kernel/requirementsourcemodel/model_test.go create mode 100644 internal/kernel/requirementsourcemodel/mutant_relation_negative_test.go create mode 100644 internal/kernel/requirementsourcemodel/mutant_relations_test.go create mode 100644 internal/kernel/requirementsourcemodel/mutants_test.go create mode 100644 internal/kernel/requirementsourcemodel/normalize.go create mode 100644 internal/kernel/requirementsourcemodel/package_boundary_test.go create mode 100644 internal/kernel/requirementsourcemodel/projection_budget.go create mode 100644 internal/kernel/requirementsourcemodel/projection_budget_test.go create mode 100644 internal/kernel/requirementsourcemodel/projection_observation_test.go create mode 100644 internal/kernel/requirementsourcemodel/reference_closure.go create mode 100644 internal/kernel/requirementsourcemodel/scenario_evaluation.go create mode 100644 internal/kernel/requirementsourcemodel/scenario_evaluation_test.go create mode 100644 internal/kernel/requirementsourcemodel/scenario_normalization.go create mode 100644 internal/kernel/requirementsourcemodel/testdata/field-projection-manifest.v1.json create mode 100644 internal/kernel/requirementsourcemodel/testdata/model-mutants.v1.json create mode 100644 internal/kernel/requirementsourcemodel/types.go create mode 100644 internal/kernel/requirementsourcemodel/validation.go create mode 100644 internal/kernel/requirementsourcemodel/variant_inventory_test.go create mode 100644 internal/kernel/requirementsourcemodel/variants_test.go diff --git a/BACKLOG.md b/BACKLOG.md index bdb44b3..b739c94 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -48,8 +48,7 @@ records, generated release manifests, or the owning docs named above. | Status | ID | Scope | Completion condition | |---|---|---|---| -| NEXT | SOURCE-MODEL-01 | Define one representation-neutral typed requirement-source v2 model before selecting a source syntax. | A private bounded model admits atomic requirement identities, grouped authoring, premises, scenarios, definitions, vocabulary, lifecycle, references, and deterministic normalization; an independently authored field/variant completeness manifest plus mutant corpus proves every normative field reaches each required downstream owner, with no production parser or persisted normalized mirror. | -| BLOCKED | 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. | +| 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 a661e5b..435eea1 100644 --- a/docs/specs/proofkit-spec-proof-core/overview.md +++ b/docs/specs/proofkit-spec-proof-core/overview.md @@ -142,6 +142,14 @@ execution receipts, and merge policy. evidence planes, consumes the normalized v1/v2 context boundary, and accepts code topology only as explicit caller-owned input with source-digest, parent-edge, abstraction-order, and pre-materialization budget closure. +- `REQ-PROOFKIT-SPEC-024`: a private representation-neutral requirement-source + v2 model separates immutable atomic, authoring-layout, and typed-reference + projections, closes every metadata owner, group-member relation, and typed + reference under independently observed input, instantiated-scenario, and + expanded-output bounds with fixed budget-error precedence, and proves exact + 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. ## 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 4b14bff..4914c79 100644 --- a/docs/specs/proofkit-spec-proof-core/requirements.v1.json +++ b/docs/specs/proofkit-spec-proof-core/requirements.v1.json @@ -555,6 +555,19 @@ "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, "deferral": null, "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-024", + "ownerId": "proofkit.spec-proof-core", + "invariant": "A private representation-neutral requirement-source v2 model admits one bounded source into immutable atomic, authoring-layout, and typed-reference projections; it preserves atomic requirement identity while expanding grouped statement stems and premises, preserves group-to-member relations under composite identity, assigns every effective metadata field to exactly one resolving profile or member owner, admits lifecycle, deferral, update-policy, scenario, vocabulary, non-claim, and derivation semantics through closed variants and references, applies architecture-independent input, instantiated-scenario, and expanded-materialization cardinality and text budgets in a fixed precedence before per-item semantic work, rejects both lexical and single-pass parameter-instantiated observation contradictions, normalizes admitted set-like values deterministically, emits nondisclosing diagnostics deterministically for each exact input, and preserves ordered action sequences. An independently authored exact typed-field and closed-variant manifest plus an identity-aware admitted semantic mutant corpus proves individual field projection, both metadata-owner branches, or an explicitly named referential relation with negative near-miss controls and without attributing correlated edits to independent field causality; independent structural observers prove exact package exports and direct imports, representation neutrality, exact input, instantiated-scenario, and expanded-output costs, and exact versus limit-minus-one budget behavior.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-024"], + "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."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} } ], "nonClaims": [ diff --git a/internal/kernel/requirementsourcemodel/accessor_immutability_test.go b/internal/kernel/requirementsourcemodel/accessor_immutability_test.go new file mode 100644 index 0000000..df078f7 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/accessor_immutability_test.go @@ -0,0 +1,155 @@ +package requirementsourcemodel + +import ( + "reflect" + "testing" +) + +func assertAccessorReturnsDetachedState[T any](t *testing.T, name string, accessor func() T) { + t.Helper() + baseline := detachedTestCopy(accessor()) + mutated := accessor() + mutationCount := mutateReferencedState(reflect.ValueOf(&mutated).Elem(), false) + if mutationCount == 0 || reflect.DeepEqual(mutated, baseline) { + t.Fatalf("%s fixture exposes no mutable reference state", name) + } + if fresh := accessor(); !reflect.DeepEqual(fresh, baseline) { + t.Fatalf("%s accessor exposed mutable owner state after %d independent mutations", name, mutationCount) + } +} + +func detachedTestCopy[T any](value T) T { + copy := deepCopyTestValue(reflect.ValueOf(value)) + return copy.Interface().(T) +} + +func deepCopyTestValue(value reflect.Value) reflect.Value { + if !value.IsValid() { + return value + } + switch value.Kind() { + case reflect.Interface: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + result := reflect.New(value.Type()).Elem() + result.Set(deepCopyTestValue(value.Elem())) + return result + case reflect.Pointer: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + result := reflect.New(value.Type().Elem()) + result.Elem().Set(deepCopyTestValue(value.Elem())) + return result + case reflect.Slice: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + result := reflect.MakeSlice(value.Type(), value.Len(), value.Len()) + for index := 0; index < value.Len(); index++ { + result.Index(index).Set(deepCopyTestValue(value.Index(index))) + } + return result + case reflect.Map: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + result := reflect.MakeMapWithSize(value.Type(), value.Len()) + iterator := value.MapRange() + for iterator.Next() { + result.SetMapIndex(deepCopyTestValue(iterator.Key()), deepCopyTestValue(iterator.Value())) + } + return result + case reflect.Struct: + result := reflect.New(value.Type()).Elem() + for index := 0; index < value.NumField(); index++ { + result.Field(index).Set(deepCopyTestValue(value.Field(index))) + } + return result + case reflect.Array: + result := reflect.New(value.Type()).Elem() + for index := 0; index < value.Len(); index++ { + result.Index(index).Set(deepCopyTestValue(value.Index(index))) + } + return result + default: + return value + } +} + +func mutateReferencedState(value reflect.Value, behindReference bool) int { + if !value.IsValid() { + return 0 + } + switch value.Kind() { + case reflect.Interface: + if value.IsNil() { + return 0 + } + copy := reflect.New(value.Elem().Type()).Elem() + copy.Set(value.Elem()) + count := mutateReferencedState(copy, behindReference) + if count != 0 && value.CanSet() { + value.Set(copy) + } + return count + case reflect.Pointer: + if value.IsNil() { + return 0 + } + return mutateReferencedState(value.Elem(), true) + case reflect.Slice: + count := 0 + for index := 0; index < value.Len(); index++ { + count += mutateReferencedState(value.Index(index), true) + } + return count + case reflect.Map: + count := 0 + iterator := value.MapRange() + for iterator.Next() { + entry := reflect.New(value.Type().Elem()).Elem() + entry.Set(iterator.Value()) + entryCount := mutateReferencedState(entry, true) + if entryCount != 0 { + value.SetMapIndex(iterator.Key(), entry) + count += entryCount + } + } + return count + case reflect.Struct: + count := 0 + for index := 0; index < value.NumField(); index++ { + count += mutateReferencedState(value.Field(index), behindReference) + } + return count + case reflect.Array: + count := 0 + for index := 0; index < value.Len(); index++ { + count += mutateReferencedState(value.Index(index), behindReference) + } + return count + case reflect.String: + if behindReference && value.CanSet() { + value.SetString(value.String() + ".mutated") + return 1 + } + case reflect.Bool: + if behindReference && value.CanSet() { + value.SetBool(!value.Bool()) + return 1 + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if behindReference && value.CanSet() { + value.SetInt(value.Int() + 1) + return 1 + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if behindReference && value.CanSet() { + value.SetUint(value.Uint() + 1) + return 1 + } + } + return 0 +} diff --git a/internal/kernel/requirementsourcemodel/clone.go b/internal/kernel/requirementsourcemodel/clone.go new file mode 100644 index 0000000..e585b8b --- /dev/null +++ b/internal/kernel/requirementsourcemodel/clone.go @@ -0,0 +1,182 @@ +package requirementsourcemodel + +func cloneDraft(value Draft) Draft { + profiles := make([]Profile, len(value.Profiles)) + for index, profile := range value.Profiles { + profiles[index] = Profile{ProfileID: profile.ProfileID, Fields: cloneMetadataFields(profile.Fields)} + } + groups := make([]Group, len(value.Groups)) + for index, group := range value.Groups { + groups[index] = cloneGroup(group) + } + derivations := make([]Derivation, len(value.Derivations)) + for index, derivation := range value.Derivations { + derivations[index] = cloneDerivation(derivation) + } + return Draft{ + SourceID: value.SourceID, + SpecPackagePath: value.SpecPackagePath, + SourceNonClaimRefs: cloneStrings(value.SourceNonClaimRefs), + NonClaimDefinitions: append([]NonClaimDefinition(nil), value.NonClaimDefinitions...), + Vocabulary: append([]VocabularyTerm(nil), value.Vocabulary...), + Derivations: derivations, + Profiles: profiles, + Groups: groups, + Scenarios: cloneScenarios(value.Scenarios), + } +} + +func cloneAtomicProjection(value AtomicProjection) AtomicProjection { + return AtomicProjection{ + SourceID: value.SourceID, + SpecPackagePath: value.SpecPackagePath, + SourceNonClaimRefs: cloneStrings(value.SourceNonClaimRefs), + NonClaimDefinitions: append([]NonClaimDefinition(nil), value.NonClaimDefinitions...), + Vocabulary: append([]VocabularyTerm(nil), value.Vocabulary...), + Requirements: cloneAtomicRequirements(value.Requirements), + Scenarios: cloneScenarios(value.Scenarios), + } +} + +func cloneAtomicRequirements(values []AtomicRequirement) []AtomicRequirement { + result := make([]AtomicRequirement, len(values)) + for index, value := range values { + result[index] = AtomicRequirement{ + RequirementID: value.RequirementID, + Invariant: value.Invariant, + SharedPremises: cloneStrings(value.SharedPremises), + OwnerID: value.OwnerID, + ClaimLevel: value.ClaimLevel, + RiskClass: value.RiskClass, + NonClaimRefs: cloneStrings(value.NonClaimRefs), + Lifecycle: cloneLifecycle(value.Lifecycle), + Deferral: cloneDeferral(value.Deferral), + UpdatePolicy: value.UpdatePolicy, + } + } + return result +} + +func cloneLayoutProjection(value LayoutProjection) LayoutProjection { + profiles := make([]Profile, len(value.Profiles)) + for index, profile := range value.Profiles { + profiles[index] = Profile{ProfileID: profile.ProfileID, Fields: cloneMetadataFields(profile.Fields)} + } + groups := make([]Group, len(value.Groups)) + for index, group := range value.Groups { + groups[index] = cloneGroup(group) + } + origins := make([]Origin, len(value.Origins)) + for index, origin := range value.Origins { + origins[index] = Origin{ + RequirementID: origin.RequirementID, + GroupID: origin.GroupID, + ProfileID: origin.ProfileID, + FieldOwners: append([]FieldOwner(nil), origin.FieldOwners...), + } + } + return LayoutProjection{SourceID: value.SourceID, Profiles: profiles, Groups: groups, Origins: origins} +} + +func cloneReferenceProjection(value ReferenceProjection) ReferenceProjection { + derivations := make([]Derivation, len(value.Derivations)) + for index, derivation := range value.Derivations { + derivations[index] = cloneDerivation(derivation) + } + return ReferenceProjection{ + SourceID: value.SourceID, + Derivations: derivations, + Edges: append([]ReferenceEdge(nil), value.Edges...), + } +} + +func cloneGroup(value Group) Group { + members := make([]Member, len(value.Members)) + for index, member := range value.Members { + members[index] = Member{ + RequirementID: member.RequirementID, + StatementCompletion: member.StatementCompletion, + Fields: cloneMetadataFields(member.Fields), + } + } + return Group{ + GroupID: value.GroupID, + ProfileID: value.ProfileID, + StatementStem: value.StatementStem, + SharedPremises: cloneStrings(value.SharedPremises), + Members: members, + } +} + +func cloneMetadataFields(value MetadataFields) MetadataFields { + result := value + if value.NonClaimRefs.Present { + result.NonClaimRefs.Value = cloneStrings(value.NonClaimRefs.Value) + } + if value.Lifecycle.Present { + result.Lifecycle.Value = cloneLifecycle(value.Lifecycle.Value) + } + if value.Deferral.Present { + result.Deferral.Value = cloneDeferral(value.Deferral.Value) + } + return result +} + +func cloneLifecycle(value Lifecycle) Lifecycle { + return Lifecycle{ + State: value.State, + ReplacementRequirementIDs: cloneStrings(value.ReplacementRequirementIDs), + EvidenceRefs: cloneStrings(value.EvidenceRefs), + } +} + +func cloneDeferral(value *Deferral) *Deferral { + if value == nil { + return nil + } + result := *value + result.EvidenceRefs = cloneStrings(value.EvidenceRefs) + return &result +} + +func cloneScenarios(values []Scenario) []Scenario { + result := make([]Scenario, len(values)) + for index, value := range values { + examples := make([]Example, len(value.Examples)) + for exampleIndex, example := range value.Examples { + items := make(map[string]ScenarioValue, len(example.Values)) + for key, item := range example.Values { + items[key] = item + } + examples[exampleIndex] = Example{ExampleID: example.ExampleID, Values: items} + } + result[index] = Scenario{ + ScenarioID: value.ScenarioID, + RequirementIDs: cloneStrings(value.RequirementIDs), + Parameters: cloneStrings(value.Parameters), + Preconditions: cloneStrings(value.Preconditions), + ActionSequence: cloneStrings(value.ActionSequence), + ExpectedObservations: cloneStrings(value.ExpectedObservations), + ForbiddenObservations: cloneStrings(value.ForbiddenObservations), + Examples: examples, + VocabularyRefs: cloneStrings(value.VocabularyRefs), + NonClaimRefs: cloneStrings(value.NonClaimRefs), + } + } + return result +} + +func cloneDerivation(value Derivation) Derivation { + return Derivation{ + DerivationID: value.DerivationID, + SourceKind: value.SourceKind, + SourceRef: value.SourceRef, + Selector: value.Selector, + RequirementIDs: cloneStrings(value.RequirementIDs), + NonClaimRefs: cloneStrings(value.NonClaimRefs), + } +} + +func cloneStrings(values []string) []string { + return append([]string(nil), values...) +} diff --git a/internal/kernel/requirementsourcemodel/completeness_test.go b/internal/kernel/requirementsourcemodel/completeness_test.go new file mode 100644 index 0000000..926ef34 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/completeness_test.go @@ -0,0 +1,385 @@ +package requirementsourcemodel + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "reflect" + "sort" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" +) + +type completenessManifest struct { + SchemaVersion int `json:"schemaVersion"` + Kind string `json:"kind"` + Projections []string `json:"projections"` + Fields []manifestField `json:"fields"` + Variants []manifestVariant `json:"variants"` + NonClaims []string `json:"nonClaims"` +} + +type manifestField struct { + FieldID string `json:"fieldId"` + RequiredProjections []string `json:"requiredProjections"` +} + +type manifestVariant struct { + VariantID string `json:"variantId"` + Values []string `json:"values"` + RequiredProjections []string `json:"requiredProjections"` +} + +type mutantCorpus struct { + SchemaVersion int `json:"schemaVersion"` + Kind string `json:"kind"` + Mutants []mutantRecord `json:"mutants"` + NonClaims []string `json:"nonClaims"` +} + +type mutantRecord struct { + MutantID string `json:"mutantId"` + MutatedFields []string `json:"mutatedFields"` + ProvenFields []string `json:"provenFields"` + RelationIDs []string `json:"relationIds"` + ChangedProjections []string `json:"changedProjections"` +} + +func TestIndependentManifestClosesEveryFieldThroughRealMutants(t *testing.T) { + manifest := readStrictJSON[completenessManifest](t, "testdata/field-projection-manifest.v1.json") + corpus := readStrictJSON[mutantCorpus](t, "testdata/model-mutants.v1.json") + if manifest.SchemaVersion != 1 || manifest.Kind != "proofkit.requirement-source-model-field-projection-manifest" { + t.Fatal("unexpected completeness manifest identity") + } + if corpus.SchemaVersion != 1 || corpus.Kind != "proofkit.requirement-source-model-mutant-corpus" { + t.Fatal("unexpected mutant corpus identity") + } + if !reflect.DeepEqual(manifest.Projections, []string{"atomic", "layout", "references"}) { + t.Fatalf("projection inventory = %#v", manifest.Projections) + } + + fields := map[string][]string{} + for _, field := range manifest.Fields { + if field.FieldID == "" || len(field.RequiredProjections) == 0 { + t.Fatalf("invalid field row %#v", field) + } + if _, exists := fields[field.FieldID]; exists { + t.Fatalf("duplicate field %q", field.FieldID) + } + assertProjectionSet(t, field.RequiredProjections) + fields[field.FieldID] = field.RequiredProjections + } + + baseline, err := Normalize(validDraft()) + if err != nil { + t.Fatal(err) + } + covered := map[string]map[string]struct{}{} + seenMutants := map[string]struct{}{} + seenRelations := map[string]struct{}{} + for _, mutant := range corpus.Mutants { + if _, exists := seenMutants[mutant.MutantID]; exists { + t.Fatalf("duplicate mutant %q", mutant.MutantID) + } + seenMutants[mutant.MutantID] = struct{}{} + assertSortedUniqueStrings(t, mutant.MutatedFields, mutant.MutantID+" mutatedFields") + assertSortedUniqueStrings(t, mutant.ProvenFields, mutant.MutantID+" provenFields") + assertSortedUniqueStrings(t, mutant.RelationIDs, mutant.MutantID+" relationIds") + assertProjectionSet(t, mutant.ChangedProjections) + if len(mutant.ProvenFields) != 0 { + if len(mutant.MutatedFields) != 1 || len(mutant.ProvenFields) != 1 || mutant.MutatedFields[0] != mutant.ProvenFields[0] || len(mutant.RelationIDs) != 0 { + t.Fatalf("mutant %q attributes independent causality to a correlated edit", mutant.MutantID) + } + } else if len(mutant.RelationIDs) == 0 { + t.Fatalf("mutant %q proves neither a field nor a relation", mutant.MutantID) + } + for _, relationID := range mutant.RelationIDs { + if _, exists := seenRelations[relationID]; exists { + t.Fatalf("duplicate relation %q", relationID) + } + seenRelations[relationID] = struct{}{} + } + draft := validDraft() + if !applyMutant(mutant.MutantID, &draft) { + t.Fatalf("mutant %q has no independently implemented edit", mutant.MutantID) + } + changed, err := Normalize(draft) + if err != nil { + t.Fatalf("mutant %q is not an admitted semantic mutation: %v", mutant.MutantID, err) + } + actual := changedProjectionIDs(baseline, changed) + if !reflect.DeepEqual(actual, mutant.ChangedProjections) { + t.Fatalf("mutant %q changed projections %v, want %v", mutant.MutantID, actual, mutant.ChangedProjections) + } + baselineDraft := validDraft() + actualMutatedFields := changedDraftFieldIDs(baselineDraft, draft, fields) + if !reflect.DeepEqual(actualMutatedFields, mutant.MutatedFields) { + t.Fatalf("mutant %q changed input fields %v, declared %v", mutant.MutantID, actualMutatedFields, mutant.MutatedFields) + } + if err := validateMutantRelations(mutant, baselineDraft, draft); err != nil { + t.Fatalf("mutant %q relation proof failed: %v", mutant.MutantID, err) + } + proven := map[string]struct{}{} + for _, fieldID := range mutant.ProvenFields { + proven[fieldID] = struct{}{} + } + for _, fieldID := range mutant.MutatedFields { + required, exists := fields[fieldID] + if !exists { + t.Fatalf("mutant %q covers unknown field %q", mutant.MutantID, fieldID) + } + observed := false + for _, projection := range required { + if !contains(mutant.ChangedProjections, projection) { + continue + } + baselineObservation, handled := observeManifestField(baseline, fieldID, projection) + if !handled { + t.Fatalf("field %q has no independent %q observer", fieldID, projection) + } + changedObservation, handled := observeManifestField(changed, fieldID, projection) + if !handled || reflect.DeepEqual(baselineObservation, changedObservation) { + t.Fatalf("mutant %q did not change field-specific %s observation for %s", mutant.MutantID, projection, fieldID) + } + observed = true + if _, independentlyProven := proven[fieldID]; independentlyProven || len(mutant.RelationIDs) != 0 { + if covered[fieldID] == nil { + covered[fieldID] = map[string]struct{}{} + } + covered[fieldID][projection] = struct{}{} + } + } + if !observed { + t.Fatalf("mutant %q has no field-specific changed observation for %s", mutant.MutantID, fieldID) + } + } + } + for fieldID, projections := range fields { + for _, projection := range projections { + if _, exists := covered[fieldID][projection]; !exists { + t.Fatalf("field %q lacks a real mutant reaching %q", fieldID, projection) + } + } + } + if len(seenMutants) != len(mutantImplementations()) { + t.Fatalf("mutant corpus has %d rows, implementation registry has %d", len(seenMutants), len(mutantImplementations())) + } + if len(seenRelations) != len(mutantRelationRegistry()) { + t.Fatalf("mutant corpus uses %d relations, registry has %d", len(seenRelations), len(mutantRelationRegistry())) + } +} + +func assertSortedUniqueStrings(t *testing.T, values []string, label string) { + t.Helper() + if len(values) == 0 { + return + } + sorted := append([]string(nil), values...) + sort.Strings(sorted) + if !reflect.DeepEqual(values, sorted) { + t.Fatalf("%s is not sorted: %v", label, values) + } + for index := 1; index < len(values); index++ { + if values[index-1] == values[index] { + t.Fatalf("%s contains duplicate %q", label, values[index]) + } + } +} + +func TestIndependentManifestClosesEnumAndPresenceVariants(t *testing.T) { + manifest := readStrictJSON[completenessManifest](t, "testdata/field-projection-manifest.v1.json") + type expectedVariant struct { + values []string + projections []string + } + expected := map[string]expectedVariant{ + "claimLevel": {values: []string{"advisory", "blocking", "deferred"}, projections: []string{"atomic", "layout"}}, + "riskClass": {values: []string{"critical", "high", "low", "medium"}, projections: []string{"atomic", "layout"}}, + "lifecycleState": {values: []string{"active", "deprecated", "removed", "superseded"}, projections: []string{"atomic", "layout"}}, + "termKind": {values: []string{"action", "observable", "state", "subject", "value"}, projections: []string{"atomic"}}, + "sourceKind": {values: []string{"clarification", "code_snapshot", "design", "owner_decision", "plan"}, projections: []string{"references"}}, + "objectFormat": {values: []string{"sha1", "sha256"}, projections: []string{"references"}}, + "entityKind": {values: []string{"derivation", "group", "nonclaim", "profile", "requirement", "scenario", "source", "term"}, projections: []string{"references"}}, + "referenceKind": {values: []string{"derivation_nonclaim", "derivation_requirement", "group_member", "group_profile", "lifecycle_replacement", "requirement_nonclaim", "scenario_nonclaim", "scenario_requirement", "scenario_vocabulary", "source_nonclaim"}, projections: []string{"references"}}, + "metadataOwner": {values: []string{"member", "profile"}, projections: []string{"layout"}}, + "deferral": {values: []string{"explicit_null", "record"}, projections: []string{"atomic", "layout"}}, + "profileRef": {values: []string{"absent", "present"}, projections: []string{"layout", "references"}}, + "scenarioValue": {values: []string{"string"}, projections: []string{"atomic"}}, + } + actual := map[string]expectedVariant{} + for _, variant := range manifest.Variants { + if _, exists := actual[variant.VariantID]; exists { + t.Fatalf("duplicate variant %q", variant.VariantID) + } + assertProjectionSet(t, variant.RequiredProjections) + actual[variant.VariantID] = expectedVariant{variant.Values, variant.RequiredProjections} + } + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("variant manifest = %#v, want %#v", actual, expected) + } +} + +func readStrictJSON[T any](t *testing.T, path string) T { + t.Helper() + source, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + result, err := decodeExactTestJSON[T](source) + if err != nil { + t.Fatalf("strict typed JSON admission failed for %s: %v", path, err) + } + return result +} + +func decodeExactTestJSON[T any](source []byte) (T, error) { + result, err := admission.DecodeTypedJSON[T](bytes.NewReader(source), 1<<20) + if err != nil { + return result, err + } + decoder := json.NewDecoder(bytes.NewReader(source)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&result); err != nil { + return result, err + } + if err := validateExactCompletenessRecord(source, result); err != nil { + return result, err + } + return result, nil +} + +func validateExactCompletenessRecord[T any](source []byte, result T) error { + var object map[string]json.RawMessage + if err := json.Unmarshal(source, &object); err != nil { + return err + } + var requiredKeys []string + var nonClaims []string + switch value := any(result).(type) { + case completenessManifest: + requiredKeys = []string{"fields", "kind", "nonClaims", "projections", "schemaVersion", "variants"} + nonClaims = value.NonClaims + case mutantCorpus: + requiredKeys = []string{"kind", "mutants", "nonClaims", "schemaVersion"} + nonClaims = value.NonClaims + default: + return fmt.Errorf("unsupported completeness record type %T", result) + } + actualKeys := make([]string, 0, len(object)) + for key := range object { + actualKeys = append(actualKeys, key) + } + sort.Strings(actualKeys) + if !reflect.DeepEqual(actualKeys, requiredKeys) { + return fmt.Errorf("top-level keys %v do not match %v", actualKeys, requiredKeys) + } + if len(nonClaims) == 0 { + return fmt.Errorf("nonClaims must be a non-empty array") + } + for index, value := range nonClaims { + if value == "" || value != strings.TrimSpace(value) { + return fmt.Errorf("nonClaims[%d] is not canonical text", index) + } + if index > 0 && nonClaims[index-1] >= value { + return fmt.Errorf("nonClaims must be sorted and unique") + } + } + return nil +} + +func TestCompletenessRecordsUseExactStrictJSONAdmission(t *testing.T) { + tests := []struct { + name string + decode func() error + }{ + {name: "case folded", decode: func() error { + _, err := decodeExactTestJSON[completenessManifest]([]byte(`{"SchemaVersion":1}`)) + return err + }}, + {name: "duplicate", decode: func() error { + _, err := decodeExactTestJSON[completenessManifest]([]byte(`{"schemaVersion":1,"schemaVersion":1}`)) + return err + }}, + {name: "trailing value", decode: func() error { + _, err := decodeExactTestJSON[completenessManifest]([]byte(`{"schemaVersion":1}{}`)) + return err + }}, + {name: "unknown", decode: func() error { + _, err := decodeExactTestJSON[completenessManifest]([]byte(`{"schemaVersion":1,"unexpected":true}`)) + return err + }}, + {name: "missing nonclaims", decode: func() error { + _, err := decodeExactTestJSON[completenessManifest]([]byte(`{"fields":[],"kind":"manifest","projections":[],"schemaVersion":1,"variants":[]}`)) + return err + }}, + {name: "null nonclaims", decode: func() error { + _, err := decodeExactTestJSON[completenessManifest]([]byte(`{"fields":[],"kind":"manifest","nonClaims":null,"projections":[],"schemaVersion":1,"variants":[]}`)) + return err + }}, + {name: "empty nonclaims", decode: func() error { + _, err := decodeExactTestJSON[completenessManifest]([]byte(`{"fields":[],"kind":"manifest","nonClaims":[],"projections":[],"schemaVersion":1,"variants":[]}`)) + return err + }}, + {name: "unsorted nonclaims", decode: func() error { + _, err := decodeExactTestJSON[completenessManifest]([]byte(`{"fields":[],"kind":"manifest","nonClaims":["Second.","First."],"projections":[],"schemaVersion":1,"variants":[]}`)) + return err + }}, + {name: "mutant corpus missing nonclaims", decode: func() error { + _, err := decodeExactTestJSON[mutantCorpus]([]byte(`{"kind":"corpus","mutants":[],"schemaVersion":1}`)) + return err + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := test.decode(); err == nil { + t.Fatal("malformed completeness record was admitted") + } + }) + } +} + +func assertProjectionSet(t *testing.T, values []string) { + t.Helper() + if len(values) == 0 { + t.Fatal("projection set is empty") + } + sorted := append([]string(nil), values...) + sort.Strings(sorted) + if !reflect.DeepEqual(values, sorted) { + t.Fatalf("projection set is not sorted: %v", values) + } + for index, value := range values { + if value != "atomic" && value != "layout" && value != "references" { + t.Fatalf("unknown projection %q", value) + } + if index > 0 && values[index-1] == value { + t.Fatalf("duplicate projection %q", value) + } + } +} + +func changedProjectionIDs(left Model, right Model) []string { + changed := []string{} + if !reflect.DeepEqual(left.Atomic(), right.Atomic()) { + changed = append(changed, "atomic") + } + if !reflect.DeepEqual(left.Layout(), right.Layout()) { + changed = append(changed, "layout") + } + if !reflect.DeepEqual(left.References(), right.References()) { + changed = append(changed, "references") + } + return changed +} + +func contains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/internal/kernel/requirementsourcemodel/draft_observation_test.go b/internal/kernel/requirementsourcemodel/draft_observation_test.go new file mode 100644 index 0000000..c3cbf5a --- /dev/null +++ b/internal/kernel/requirementsourcemodel/draft_observation_test.go @@ -0,0 +1,314 @@ +package requirementsourcemodel + +import ( + "encoding/json" + "reflect" + "sort" + "testing" +) + +type keyedDraftValue struct { + identity string + value any +} + +type keyedDraftObservation struct { + identities []string + valuesByIdentity map[string]string + valueMultiset []string +} + +type draftMetadataValue struct { + OwnerKind MetadataOwnerKind + Present bool + Value any +} + +func changedDraftFieldIDs(before Draft, after Draft, fields map[string][]string) []string { + changed := []string{} + for fieldID := range fields { + left, leftHandled := observeDraftField(before, fieldID) + right, rightHandled := observeDraftField(after, fieldID) + if !leftHandled || !rightHandled { + panic("unhandled draft field: " + fieldID) + } + if !draftFieldObservationsEqual(left, right) { + changed = append(changed, fieldID) + } + } + sort.Strings(changed) + return changed +} + +func observeDraftField(draft Draft, fieldID string) (any, bool) { + switch fieldID { + case "source.id": + return draft.SourceID, true + case "source.specPackagePath": + return draft.SpecPackagePath, true + case "source.nonClaimRefs": + return sortedObservationStrings(draft.SourceNonClaimRefs), true + case "nonClaim.id": + return projectIdentities(draft.NonClaimDefinitions, func(value NonClaimDefinition) string { return value.NonClaimID }), true + case "nonClaim.statement": + return projectKeyedValues(draft.NonClaimDefinitions, func(value NonClaimDefinition) string { return value.NonClaimID }, func(value NonClaimDefinition) any { return value.Statement }), true + case "vocabulary.id": + return projectIdentities(draft.Vocabulary, func(value VocabularyTerm) string { return value.TermID }), true + case "vocabulary.kind": + return projectKeyedValues(draft.Vocabulary, func(value VocabularyTerm) string { return value.TermID }, func(value VocabularyTerm) any { return value.Kind }), true + case "vocabulary.label": + return projectKeyedValues(draft.Vocabulary, func(value VocabularyTerm) string { return value.TermID }, func(value VocabularyTerm) any { return value.Label }), true + case "vocabulary.definition": + return projectKeyedValues(draft.Vocabulary, func(value VocabularyTerm) string { return value.TermID }, func(value VocabularyTerm) any { return value.Definition }), true + case "derivation.id": + return projectIdentities(draft.Derivations, func(value Derivation) string { return value.DerivationID }), true + case "derivation.sourceKind": + return projectKeyedValues(draft.Derivations, derivationIdentity, func(value Derivation) any { return value.SourceKind }), true + case "derivation.sourceRef.objectFormat": + return projectKeyedValues(draft.Derivations, derivationIdentity, func(value Derivation) any { return value.SourceRef.ObjectFormat }), true + case "derivation.sourceRef.commitOid": + return projectKeyedValues(draft.Derivations, derivationIdentity, func(value Derivation) any { return value.SourceRef.CommitOID }), true + case "derivation.sourceRef.path": + return projectKeyedValues(draft.Derivations, derivationIdentity, func(value Derivation) any { return value.SourceRef.Path }), true + case "derivation.sourceRef.sha256": + return projectKeyedValues(draft.Derivations, derivationIdentity, func(value Derivation) any { return value.SourceRef.SHA256 }), true + case "derivation.selector.start": + return projectKeyedValues(draft.Derivations, derivationIdentity, func(value Derivation) any { return value.Selector.Start }), true + case "derivation.selector.end": + return projectKeyedValues(draft.Derivations, derivationIdentity, func(value Derivation) any { return value.Selector.End }), true + case "derivation.requirementIds": + return projectKeyedValues(draft.Derivations, derivationIdentity, func(value Derivation) any { return sortedObservationStrings(value.RequirementIDs) }), true + case "derivation.nonClaimRefs": + return projectKeyedValues(draft.Derivations, derivationIdentity, func(value Derivation) any { return sortedObservationStrings(value.NonClaimRefs) }), true + case "profile.id": + return projectIdentities(draft.Profiles, func(value Profile) string { return value.ProfileID }), true + case "group.id": + return projectIdentities(draft.Groups, func(value Group) string { return value.GroupID }), true + case "group.memberRefs": + return projectGroupMemberReferences(draft), true + case "group.profileRef": + return projectKeyedValues(draft.Groups, groupIdentity, func(value Group) any { return value.ProfileID }), true + case "group.statementStem": + return projectKeyedValues(draft.Groups, groupIdentity, func(value Group) any { return value.StatementStem }), true + case "group.sharedPremises": + return projectKeyedValues(draft.Groups, groupIdentity, func(value Group) any { return sortedObservationStrings(value.SharedPremises) }), true + case "member.requirementId": + return projectDraftMemberIdentities(draft), true + case "member.statementCompletion": + return projectDraftMembers(draft, func(value Member) any { return value.StatementCompletion }), true + case "scenario.id": + return projectIdentities(draft.Scenarios, func(value Scenario) string { return value.ScenarioID }), true + case "scenario.requirementIds": + return projectKeyedValues(draft.Scenarios, scenarioIdentity, func(value Scenario) any { return sortedObservationStrings(value.RequirementIDs) }), true + case "scenario.parameters": + return projectKeyedValues(draft.Scenarios, scenarioIdentity, func(value Scenario) any { return sortedObservationStrings(value.Parameters) }), true + case "scenario.preconditions": + return projectKeyedValues(draft.Scenarios, scenarioIdentity, func(value Scenario) any { return sortedObservationStrings(value.Preconditions) }), true + case "scenario.actionSequence": + return projectKeyedValues(draft.Scenarios, scenarioIdentity, func(value Scenario) any { return value.ActionSequence }), true + case "scenario.expectedObservations": + return projectKeyedValues(draft.Scenarios, scenarioIdentity, func(value Scenario) any { return sortedObservationStrings(value.ExpectedObservations) }), true + case "scenario.forbiddenObservations": + return projectKeyedValues(draft.Scenarios, scenarioIdentity, func(value Scenario) any { return sortedObservationStrings(value.ForbiddenObservations) }), true + case "scenario.example.id": + return projectDraftExampleIdentities(draft), true + case "scenario.example.values": + return projectDraftExamples(draft, func(value Example) any { return value.Values }), true + case "scenario.vocabularyRefs": + return projectKeyedValues(draft.Scenarios, scenarioIdentity, func(value Scenario) any { return sortedObservationStrings(value.VocabularyRefs) }), true + case "scenario.nonClaimRefs": + return projectKeyedValues(draft.Scenarios, scenarioIdentity, func(value Scenario) any { return sortedObservationStrings(value.NonClaimRefs) }), true + } + if stringsHasPrefix(fieldID, "metadata.") { + return observeDraftMetadata(draft, fieldID), true + } + return nil, false +} + +func draftFieldObservationsEqual(left any, right any) bool { + leftKeyed, leftIsKeyed := left.(keyedDraftObservation) + rightKeyed, rightIsKeyed := right.(keyedDraftObservation) + if leftIsKeyed != rightIsKeyed { + return false + } + if !leftIsKeyed { + return reflect.DeepEqual(left, right) + } + if reflect.DeepEqual(leftKeyed.identities, rightKeyed.identities) { + return reflect.DeepEqual(leftKeyed.valuesByIdentity, rightKeyed.valuesByIdentity) + } + return reflect.DeepEqual(leftKeyed.valueMultiset, rightKeyed.valueMultiset) +} + +func projectIdentities[T any](values []T, identity func(T) string) []string { + result := make([]string, len(values)) + for index, value := range values { + result[index] = identity(value) + } + sort.Strings(result) + return result +} + +func projectKeyedValues[T any](values []T, identity func(T) string, project func(T) any) keyedDraftObservation { + entries := make([]keyedDraftValue, len(values)) + for index, value := range values { + entries[index] = keyedDraftValue{identity: identity(value), value: project(value)} + } + return newKeyedDraftObservation(entries) +} + +func projectDraftMemberIdentities(draft Draft) []string { + result := []string{} + for _, group := range draft.Groups { + for _, member := range group.Members { + result = append(result, member.RequirementID) + } + } + sort.Strings(result) + return result +} + +func projectGroupMemberReferences(draft Draft) []string { + result := []string{} + for _, group := range draft.Groups { + for _, member := range group.Members { + result = append(result, group.GroupID+"\x00"+member.RequirementID) + } + } + sort.Strings(result) + return result +} + +func projectDraftMembers(draft Draft, project func(Member) any) keyedDraftObservation { + entries := []keyedDraftValue{} + for _, group := range draft.Groups { + for _, member := range group.Members { + entries = append(entries, keyedDraftValue{identity: member.RequirementID, value: project(member)}) + } + } + return newKeyedDraftObservation(entries) +} + +func projectDraftExampleIdentities(draft Draft) []string { + result := []string{} + for _, scenario := range draft.Scenarios { + for _, example := range scenario.Examples { + result = append(result, example.ExampleID) + } + } + sort.Strings(result) + return result +} + +func projectDraftExamples(draft Draft, project func(Example) any) keyedDraftObservation { + entries := []keyedDraftValue{} + for _, scenario := range draft.Scenarios { + for _, example := range scenario.Examples { + identity := scenario.ScenarioID + "\x00" + example.ExampleID + entries = append(entries, keyedDraftValue{identity: identity, value: project(example)}) + } + } + return newKeyedDraftObservation(entries) +} + +func newKeyedDraftObservation(entries []keyedDraftValue) keyedDraftObservation { + result := keyedDraftObservation{ + identities: make([]string, 0, len(entries)), + valuesByIdentity: make(map[string]string, len(entries)), + valueMultiset: make([]string, 0, len(entries)), + } + for _, entry := range entries { + encoded, err := json.Marshal(entry.value) + if err != nil { + panic(err) + } + value := string(encoded) + if _, duplicate := result.valuesByIdentity[entry.identity]; duplicate { + panic("duplicate observation identity: " + entry.identity) + } + result.identities = append(result.identities, entry.identity) + result.valuesByIdentity[entry.identity] = value + result.valueMultiset = append(result.valueMultiset, value) + } + sort.Strings(result.identities) + sort.Strings(result.valueMultiset) + return result +} + +func observeDraftMetadata(draft Draft, fieldID string) keyedDraftObservation { + entries := []keyedDraftValue{} + for _, profile := range draft.Profiles { + present, value := metadataFieldValue(profile.Fields, fieldID) + if present && deferralSubfieldExists(profile.Fields, fieldID) { + entries = append(entries, keyedDraftValue{ + identity: "profile:" + profile.ProfileID, + value: draftMetadataValue{OwnerKind: MetadataOwnerProfile, Present: present, Value: value}, + }) + } + } + for _, group := range draft.Groups { + for _, member := range group.Members { + present, value := metadataFieldValue(member.Fields, fieldID) + if present && deferralSubfieldExists(member.Fields, fieldID) { + entries = append(entries, keyedDraftValue{ + identity: "member:" + member.RequirementID, + value: draftMetadataValue{OwnerKind: MetadataOwnerMember, Present: present, Value: value}, + }) + } + } + } + return newKeyedDraftObservation(entries) +} + +func derivationIdentity(value Derivation) string { return value.DerivationID } + +func groupIdentity(value Group) string { return value.GroupID } + +func scenarioIdentity(value Scenario) string { return value.ScenarioID } + +func sortedObservationStrings(values []string) []string { + result := append([]string(nil), values...) + sort.Strings(result) + return result +} + +func TestChangedDraftFieldIDsIsIdentityAware(t *testing.T) { + manifest := readStrictJSON[completenessManifest](t, "testdata/field-projection-manifest.v1.json") + fields := make(map[string][]string, len(manifest.Fields)) + for _, field := range manifest.Fields { + fields[field.FieldID] = field.RequiredProjections + } + + baseline := validDraft() + permuted := cloneDraft(baseline) + reverseDefinitions(permuted.NonClaimDefinitions) + if changed := changedDraftFieldIDs(baseline, permuted, fields); len(changed) != 0 { + t.Fatalf("set permutation changed fields %v", changed) + } + + baseline.NonClaimDefinitions = append(baseline.NonClaimDefinitions, NonClaimDefinition{ + NonClaimID: "NCL-MODEL-005", + Statement: "A fifth bounded non-claim.", + }) + baseline.SourceNonClaimRefs = append(baseline.SourceNonClaimRefs, "NCL-MODEL-005") + swapped := cloneDraft(baseline) + swapped.NonClaimDefinitions[0].NonClaimID, swapped.NonClaimDefinitions[4].NonClaimID = + swapped.NonClaimDefinitions[4].NonClaimID, swapped.NonClaimDefinitions[0].NonClaimID + changed := changedDraftFieldIDs(baseline, swapped, fields) + want := []string{"nonClaim.statement"} + if !reflect.DeepEqual(changed, want) { + t.Fatalf("identity reassignment changed fields %v, want %v", changed, want) + } + + moved := cloneDraft(validDraft()) + moved.Groups[1].Members[0], moved.Groups[2].Members[0] = moved.Groups[2].Members[0], moved.Groups[1].Members[0] + changed = changedDraftFieldIDs(validDraft(), moved, fields) + want = []string{"group.memberRefs"} + if !reflect.DeepEqual(changed, want) { + t.Fatalf("group membership reassignment changed fields %v, want %v", changed, want) + } +} + +func deferralSubfieldExists(fields MetadataFields, fieldID string) bool { + return fieldID == "metadata.deferral.presence" || !stringsHasPrefix(fieldID, "metadata.deferral.") || fields.Deferral.Value != nil +} diff --git a/internal/kernel/requirementsourcemodel/field_inventory_test.go b/internal/kernel/requirementsourcemodel/field_inventory_test.go new file mode 100644 index 0000000..d1b3c6e --- /dev/null +++ b/internal/kernel/requirementsourcemodel/field_inventory_test.go @@ -0,0 +1,315 @@ +package requirementsourcemodel + +import ( + "go/types" + "reflect" + "sort" + "strings" + "testing" + + "golang.org/x/tools/go/packages" +) + +// This inventory is intentionally independent of the production normalizer and +// the JSON completeness manifest. A new typed input field must be classified +// here and in the manifest before the completeness gate can pass. +var inputFieldManifestIDs = map[string][]string{ + "ByteRange.End": {"derivation.selector.end"}, + "ByteRange.Start": {"derivation.selector.start"}, + "Deferral.EvidenceRefs": {"metadata.deferral.evidenceRefs"}, + "Deferral.ExpiryRef": {"metadata.deferral.expiryRef"}, + "Deferral.MergePolicy": {"metadata.deferral.mergePolicy"}, + "Deferral.OwnerID": {"metadata.deferral.ownerId"}, + "Deferral.ReviewCondition": {"metadata.deferral.reviewCondition"}, + "Deferral.RiskAcceptedBy": {"metadata.deferral.riskAcceptedBy"}, + "Derivation.DerivationID": {"derivation.id"}, + "Derivation.NonClaimRefs": {"derivation.nonClaimRefs"}, + "Derivation.RequirementIDs": {"derivation.requirementIds"}, + "Derivation.Selector": nil, + "Derivation.SourceKind": {"derivation.sourceKind"}, + "Derivation.SourceRef": nil, + "Draft.Derivations": nil, + "Draft.Groups": nil, + "Draft.NonClaimDefinitions": nil, + "Draft.Profiles": nil, + "Draft.Scenarios": nil, + "Draft.SourceID": {"source.id"}, + "Draft.SourceNonClaimRefs": {"source.nonClaimRefs"}, + "Draft.SpecPackagePath": {"source.specPackagePath"}, + "Draft.Vocabulary": nil, + "Example.ExampleID": {"scenario.example.id"}, + "Example.Values": {"scenario.example.values"}, + "GitBlobRef.CommitOID": {"derivation.sourceRef.commitOid"}, + "GitBlobRef.ObjectFormat": {"derivation.sourceRef.objectFormat"}, + "GitBlobRef.Path": {"derivation.sourceRef.path"}, + "GitBlobRef.SHA256": {"derivation.sourceRef.sha256"}, + "Group.GroupID": {"group.id"}, + "Group.Members": {"group.memberRefs"}, + "Group.ProfileID": {"group.profileRef"}, + "Group.SharedPremises": {"group.sharedPremises"}, + "Group.StatementStem": {"group.statementStem"}, + "Lifecycle.EvidenceRefs": {"metadata.lifecycle.evidenceRefs"}, + "Lifecycle.ReplacementRequirementIDs": {"metadata.lifecycle.replacementRequirementIds"}, + "Lifecycle.State": {"metadata.lifecycle.state"}, + "Member.Fields": nil, + "Member.RequirementID": {"member.requirementId"}, + "Member.StatementCompletion": {"member.statementCompletion"}, + "MetadataFields.ClaimLevel": {"metadata.claimLevel"}, + "MetadataFields.Deferral": {"metadata.deferral.presence"}, + "MetadataFields.Lifecycle": nil, + "MetadataFields.NonClaimRefs": {"metadata.nonClaimRefs"}, + "MetadataFields.OwnerID": {"metadata.ownerId"}, + "MetadataFields.RiskClass": {"metadata.riskClass"}, + "MetadataFields.UpdatePolicy": nil, + "NonClaimDefinition.NonClaimID": {"nonClaim.id"}, + "NonClaimDefinition.Statement": {"nonClaim.statement"}, + "Profile.Fields": nil, + "Profile.ProfileID": {"profile.id"}, + "Scenario.ActionSequence": {"scenario.actionSequence"}, + "Scenario.Examples": nil, + "Scenario.ExpectedObservations": {"scenario.expectedObservations"}, + "Scenario.ForbiddenObservations": {"scenario.forbiddenObservations"}, + "Scenario.NonClaimRefs": {"scenario.nonClaimRefs"}, + "Scenario.Parameters": {"scenario.parameters"}, + "Scenario.Preconditions": {"scenario.preconditions"}, + "Scenario.RequirementIDs": {"scenario.requirementIds"}, + "Scenario.ScenarioID": {"scenario.id"}, + "Scenario.VocabularyRefs": {"scenario.vocabularyRefs"}, + "UpdatePolicy.RequiresImpactDeclaration": {"metadata.updatePolicy.requiresImpactDeclaration"}, + "UpdatePolicy.RequiresProofBindingReview": {"metadata.updatePolicy.requiresProofBindingReview"}, + "UpdatePolicy.ReviewOwnerID": {"metadata.updatePolicy.reviewOwnerId"}, + "VocabularyTerm.Definition": {"vocabulary.definition"}, + "VocabularyTerm.Kind": {"vocabulary.kind"}, + "VocabularyTerm.Label": {"vocabulary.label"}, + "VocabularyTerm.TermID": {"vocabulary.id"}, +} + +func TestIndependentManifestMatchesExactTypedInputFieldInventory(t *testing.T) { + assertExactFieldWrapperShape(t) + assertRepresentationNeutralModelShape(t) + manifest := readStrictJSON[completenessManifest](t, "testdata/field-projection-manifest.v1.json") + actualPaths := typedInputFieldPaths(reflect.TypeOf(Draft{})) + expectedPaths := make([]string, 0, len(inputFieldManifestIDs)) + for path := range inputFieldManifestIDs { + expectedPaths = append(expectedPaths, path) + } + sort.Strings(expectedPaths) + if !reflect.DeepEqual(actualPaths, expectedPaths) { + t.Fatalf("typed input fields = %v, classified fields = %v", actualPaths, expectedPaths) + } + + manifestIDs := make([]string, 0, len(manifest.Fields)) + for _, field := range manifest.Fields { + manifestIDs = append(manifestIDs, field.FieldID) + } + sort.Strings(manifestIDs) + mappedIDs := []string{} + seen := map[string]struct{}{} + for _, ids := range inputFieldManifestIDs { + for _, id := range ids { + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + mappedIDs = append(mappedIDs, id) + } + } + sort.Strings(mappedIDs) + if !reflect.DeepEqual(manifestIDs, mappedIDs) { + t.Fatalf("manifest fields = %v, typed input mapping = %v", manifestIDs, mappedIDs) + } +} + +func assertExactFieldWrapperShape(t *testing.T) { + t.Helper() + wrapper := reflect.TypeOf(Field[string]{}) + actual := make([]string, wrapper.NumField()) + for index := 0; index < wrapper.NumField(); index++ { + field := wrapper.Field(index) + actual[index] = field.Name + ":" + field.Type.String() + ":" + string(field.Tag) + if field.Anonymous || !field.IsExported() { + t.Fatalf("Field[T].%s must remain exported and non-anonymous", field.Name) + } + } + expected := []string{"Present:bool:", "Value:string:"} + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("Field[T] structural inventory = %v, want %v", actual, expected) + } + assertMethodNames(t, wrapper, nil) + assertMethodNames(t, reflect.PointerTo(wrapper), nil) +} + +func assertRepresentationNeutralModelShape(t *testing.T) { + t.Helper() + roots := []reflect.Type{ + reflect.TypeOf(Draft{}), + reflect.TypeOf(AtomicProjection{}), + reflect.TypeOf(LayoutProjection{}), + reflect.TypeOf(ReferenceProjection{}), + reflect.TypeOf(Limits{}), + } + seen := map[reflect.Type]struct{}{} + for _, root := range roots { + inspectRepresentationNeutralType(t, root, seen) + } + assertExactProductionMethodSets(t, seen) + + model := reflect.TypeOf(Model{}) + expectedFields := []string{ + "atomic:" + reflect.TypeOf(AtomicProjection{}).String(), + "layout:" + reflect.TypeOf(LayoutProjection{}).String(), + "references:" + reflect.TypeOf(ReferenceProjection{}).String(), + } + actualFields := make([]string, model.NumField()) + for index := 0; index < model.NumField(); index++ { + field := model.Field(index) + actualFields[index] = field.Name + ":" + field.Type.String() + if field.Anonymous || field.Tag != "" || field.IsExported() { + t.Fatalf("Model.%s must remain private, untagged, and non-anonymous", field.Name) + } + } + if !reflect.DeepEqual(actualFields, expectedFields) { + t.Fatalf("Model structural inventory = %v, want %v", actualFields, expectedFields) + } + assertMethodNames(t, model, []string{"Atomic", "Layout", "References"}) + assertMethodNames(t, reflect.PointerTo(model), []string{"Atomic", "Layout", "References"}) + assertAccessorSignature(t, model, "Atomic", reflect.TypeOf(AtomicProjection{})) + assertAccessorSignature(t, model, "Layout", reflect.TypeOf(LayoutProjection{})) + assertAccessorSignature(t, model, "References", reflect.TypeOf(ReferenceProjection{})) +} + +func inspectRepresentationNeutralType(t *testing.T, value reflect.Type, seen map[reflect.Type]struct{}) { + t.Helper() + for value.Kind() == reflect.Pointer || value.Kind() == reflect.Slice || value.Kind() == reflect.Array { + value = value.Elem() + } + if value.Kind() == reflect.Map { + inspectRepresentationNeutralType(t, value.Key(), seen) + inspectRepresentationNeutralType(t, value.Elem(), seen) + return + } + if value.PkgPath() != reflect.TypeOf(Draft{}).PkgPath() { + return + } + if _, exists := seen[value]; exists { + return + } + seen[value] = struct{}{} + assertMethodNames(t, value, nil) + assertMethodNames(t, reflect.PointerTo(value), nil) + if value.Kind() != reflect.Struct { + return + } + for index := 0; index < value.NumField(); index++ { + field := value.Field(index) + if field.Anonymous || field.Tag != "" || !field.IsExported() { + t.Fatalf("%s.%s must remain exported, untagged, and non-anonymous", value.Name(), field.Name) + } + inspectRepresentationNeutralType(t, field.Type, seen) + } +} + +func assertMethodNames(t *testing.T, value reflect.Type, expected []string) { + t.Helper() + actual := make([]string, value.NumMethod()) + for index := 0; index < value.NumMethod(); index++ { + actual[index] = value.Method(index).Name + } + if len(actual) != len(expected) { + t.Fatalf("%s method set = %v, want %v", value, actual, expected) + } + for index := range actual { + if actual[index] != expected[index] { + t.Fatalf("%s method set = %v, want %v", value, actual, expected) + } + } +} + +func assertAccessorSignature(t *testing.T, receiver reflect.Type, name string, output reflect.Type) { + t.Helper() + method, exists := receiver.MethodByName(name) + if !exists || method.Type.NumIn() != 1 || method.Type.NumOut() != 1 || method.Type.Out(0) != output || method.Type.IsVariadic() { + t.Fatalf("Model.%s has unexpected signature", name) + } +} + +func assertExactProductionMethodSets(t *testing.T, reflected map[reflect.Type]struct{}) { + t.Helper() + loaded, err := packages.Load(&packages.Config{Mode: packages.NeedName | packages.NeedTypes}, ".") + if err != nil { + t.Fatal(err) + } + if packages.PrintErrors(loaded) != 0 || len(loaded) != 1 { + t.Fatalf("loaded production packages = %d", len(loaded)) + } + typeNames := map[string]struct{}{"Model": {}} + for value := range reflected { + name := value.Name() + if generic := strings.IndexByte(name, '['); generic >= 0 { + name = name[:generic] + } + if name != "" { + typeNames[name] = struct{}{} + } + } + for name := range typeNames { + object, ok := loaded[0].Types.Scope().Lookup(name).(*types.TypeName) + if !ok { + t.Fatalf("production type %s is unavailable", name) + } + named, ok := object.Type().(*types.Named) + if !ok { + t.Fatalf("production type %s is not named", name) + } + expected := []string{} + if name == "Model" { + expected = []string{"Atomic", "Layout", "References"} + } + assertGoMethodSet(t, name, types.NewMethodSet(named), expected) + assertGoMethodSet(t, "*"+name, types.NewMethodSet(types.NewPointer(named)), expected) + } +} + +func assertGoMethodSet(t *testing.T, owner string, methods *types.MethodSet, expected []string) { + t.Helper() + actual := make([]string, methods.Len()) + for index := 0; index < methods.Len(); index++ { + actual[index] = methods.At(index).Obj().Name() + } + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("%s production method set = %v, want %v", owner, actual, expected) + } +} + +func typedInputFieldPaths(root reflect.Type) []string { + seen := map[reflect.Type]struct{}{} + paths := []string{} + var visit func(reflect.Type) + visit = func(value reflect.Type) { + for value.Kind() == reflect.Pointer || value.Kind() == reflect.Slice || value.Kind() == reflect.Array || value.Kind() == reflect.Map { + value = value.Elem() + } + if value.Kind() != reflect.Struct || value.PkgPath() != reflect.TypeOf(Draft{}).PkgPath() { + return + } + if strings.HasPrefix(value.Name(), "Field[") { + field, exists := value.FieldByName("Value") + if exists { + visit(field.Type) + } + return + } + if _, exists := seen[value]; exists { + return + } + seen[value] = struct{}{} + for index := 0; index < value.NumField(); index++ { + field := value.Field(index) + paths = append(paths, value.Name()+"."+field.Name) + visit(field.Type) + } + } + visit(root) + sort.Strings(paths) + return paths +} diff --git a/internal/kernel/requirementsourcemodel/group_normalization.go b/internal/kernel/requirementsourcemodel/group_normalization.go new file mode 100644 index 0000000..3775dee --- /dev/null +++ b/internal/kernel/requirementsourcemodel/group_normalization.go @@ -0,0 +1,160 @@ +package requirementsourcemodel + +import "sort" + +func normalizeGroups(values []Group, profiles map[string]Profile) ([]Group, []AtomicRequirement, []Origin, map[string]int, error) { + groups := make([]Group, len(values)) + groupIDs := make(map[string]struct{}, len(values)) + requirementIDs := map[string]struct{}{} + requirements := []AtomicRequirement{} + origins := []Origin{} + profileUses := map[string]int{} + + for groupIndex, value := range values { + path := indexed("groups", groupIndex, "") + groupID, err := canonicalID(value.GroupID, "RGRP-", path+"groupId") + if err != nil { + return nil, nil, nil, nil, err + } + if _, exists := groupIDs[groupID]; exists { + return nil, nil, nil, nil, invalid("duplicate_id", "groups") + } + groupIDs[groupID] = struct{}{} + + profileID := "" + profileFields := MetadataFields{} + if value.ProfileID != "" { + profileID, err = canonicalID(value.ProfileID, "RPROF-", path+"profileId") + if err != nil { + return nil, nil, nil, nil, err + } + profile, exists := profiles[profileID] + if !exists { + return nil, nil, nil, nil, invalid("dangling_profile_ref", path+"profileId") + } + profileFields = profile.Fields + profileUses[profileID] += len(value.Members) + } + + stem, err := canonicalText(value.StatementStem, path+"statementStem", true, true) + if err != nil { + return nil, nil, nil, nil, err + } + if stem != "" && len(value.Members) < 2 { + return nil, nil, nil, nil, invalid("vacuous_group_stem", path+"statementStem") + } + premises, err := normalizeTexts(value.SharedPremises, path+"sharedPremises", true, true) + if err != nil { + return nil, nil, nil, nil, err + } + + members := make([]Member, len(value.Members)) + for memberIndex, memberValue := range value.Members { + memberPath := indexed(path+"members", memberIndex, "") + requirementID, err := canonicalID(memberValue.RequirementID, "REQ-", memberPath+"requirementId") + if err != nil { + return nil, nil, nil, nil, err + } + if _, exists := requirementIDs[requirementID]; exists { + return nil, nil, nil, nil, invalid("duplicate_requirement_id", "groups.members") + } + requirementIDs[requirementID] = struct{}{} + completion, err := canonicalText(memberValue.StatementCompletion, memberPath+"statementCompletion", false, true) + if err != nil { + return nil, nil, nil, nil, err + } + fields, err := normalizeMetadataFields(memberValue.Fields, memberPath+"fields") + if err != nil { + return nil, nil, nil, nil, err + } + resolved, fieldOwners, err := resolveMetadata(profileID, requirementID, profileFields, fields, memberPath+"fields") + if err != nil { + return nil, nil, nil, nil, err + } + invariant := completion + if stem != "" { + invariant = stem + " " + completion + } + resolved.RequirementID = requirementID + resolved.Invariant = invariant + resolved.SharedPremises = cloneStrings(premises) + requirements = append(requirements, resolved) + origins = append(origins, Origin{ + RequirementID: requirementID, + GroupID: groupID, + ProfileID: profileID, + FieldOwners: fieldOwners, + }) + members[memberIndex] = Member{RequirementID: requirementID, StatementCompletion: completion, Fields: fields} + } + sort.Slice(members, func(left int, right int) bool { return members[left].RequirementID < members[right].RequirementID }) + groups[groupIndex] = Group{ + GroupID: groupID, + ProfileID: profileID, + StatementStem: stem, + SharedPremises: premises, + Members: members, + } + } + + sort.Slice(groups, func(left int, right int) bool { return groups[left].GroupID < groups[right].GroupID }) + sort.Slice(requirements, func(left int, right int) bool { + return requirements[left].RequirementID < requirements[right].RequirementID + }) + sort.Slice(origins, func(left int, right int) bool { return origins[left].RequirementID < origins[right].RequirementID }) + return groups, requirements, origins, profileUses, nil +} + +func validateProfileUses(profiles []Profile, uses map[string]int) error { + for _, profile := range profiles { + if uses[profile.ProfileID] < 2 { + return invalid("vacuous_profile", "profiles."+profile.ProfileID) + } + } + return nil +} + +func validateRequirementLifecycles(requirements []AtomicRequirement, byID map[string]AtomicRequirement) error { + for _, requirement := range requirements { + path := "requirements." + requirement.RequirementID + if requirement.ClaimLevel == ClaimDeferred && requirement.Deferral == nil { + return invalid("missing_deferral", path+".deferral") + } + if requirement.ClaimLevel != ClaimDeferred && requirement.Deferral != nil { + return invalid("unexpected_deferral", path+".deferral") + } + if requirement.Lifecycle.State != LifecycleActive && len(requirement.Lifecycle.EvidenceRefs) == 0 { + return invalid("missing_lifecycle_evidence", path+".lifecycle.evidenceRefs") + } + if requirement.Lifecycle.State == LifecycleSuperseded && len(requirement.Lifecycle.ReplacementRequirementIDs) == 0 { + return invalid("missing_replacement", path+".lifecycle.replacementRequirementIds") + } + if requirement.Lifecycle.State != LifecycleSuperseded && len(requirement.Lifecycle.ReplacementRequirementIDs) != 0 { + return invalid("unexpected_replacement", path+".lifecycle.replacementRequirementIds") + } + if requirement.ClaimLevel == ClaimBlocking && requirement.Lifecycle.State != LifecycleActive { + return invalid("nonactive_blocking_requirement", path+".claimLevel") + } + if requirement.ClaimLevel == ClaimBlocking && requirement.Lifecycle.State == LifecycleActive { + if !requirement.UpdatePolicy.RequiresImpactDeclaration { + return invalid("impact_review_required", path+".updatePolicy.requiresImpactDeclaration") + } + if !requirement.UpdatePolicy.RequiresProofBindingReview { + return invalid("proof_binding_review_required", path+".updatePolicy.requiresProofBindingReview") + } + } + for _, replacementID := range requirement.Lifecycle.ReplacementRequirementIDs { + if replacementID == requirement.RequirementID { + return invalid("self_replacement", path+".lifecycle.replacementRequirementIds") + } + replacement, exists := byID[replacementID] + if !exists { + return invalid("dangling_replacement", path+".lifecycle.replacementRequirementIds") + } + if replacement.Lifecycle.State != LifecycleActive { + return invalid("inactive_replacement", path+".lifecycle.replacementRequirementIds") + } + } + } + return nil +} diff --git a/internal/kernel/requirementsourcemodel/input_budget_test.go b/internal/kernel/requirementsourcemodel/input_budget_test.go new file mode 100644 index 0000000..bbc783d --- /dev/null +++ b/internal/kernel/requirementsourcemodel/input_budget_test.go @@ -0,0 +1,373 @@ +package requirementsourcemodel + +import ( + "reflect" + "testing" +) + +func TestInputCollectionBudgetMatchesIndependentObserver(t *testing.T) { + draft := validDraft() + observed := observeInputCollectionItems(reflect.ValueOf(draft)) + if observed == 0 || observed > uint64(DefaultLimits().MaxCollectionItems) { + t.Fatalf("independent collection item count = %d", observed) + } + if !collectionItemsWithinBudget(draft, int(observed)) { + t.Fatalf("exact collection budget %d was rejected", observed) + } + if collectionItemsWithinBudget(draft, int(observed-1)) { + t.Fatalf("collection limit %d accepted %d observed items", observed-1, observed) + } +} + +func TestInputTextBudgetUsesArchitectureIndependentWidth(t *testing.T) { + var observed uint64 = draftTextBytes(validDraft()) + if observed != observeStructuredCost(validDraft()).TextBytes { + t.Fatalf("production input text bytes = %d, independently observed = %d", observed, observeStructuredCost(validDraft()).TextBytes) + } +} + +func TestLimitConfigurationRejectsEveryInvalidBoundary(t *testing.T) { + tests := []struct { + name string + hard int + set func(*Limits, int) + }{ + {"definitions", hardMaxDefinitions, func(value *Limits, limit int) { value.MaxDefinitions = limit }}, + {"derivations", hardMaxDerivations, func(value *Limits, limit int) { value.MaxDerivations = limit }}, + {"examples", hardMaxExamples, func(value *Limits, limit int) { value.MaxExamples = limit }}, + {"examples per scenario", hardMaxExamplesPerScenario, func(value *Limits, limit int) { value.MaxExamplesPerScenario = limit }}, + {"collection items", hardMaxCollectionItems, func(value *Limits, limit int) { value.MaxCollectionItems = limit }}, + {"expanded items", hardMaxExpandedItems, func(value *Limits, limit int) { value.MaxExpandedItems = limit }}, + {"expanded text", hardMaxExpandedTextBytes, func(value *Limits, limit int) { value.MaxExpandedTextBytes = limit }}, + {"groups", hardMaxGroups, func(value *Limits, limit int) { value.MaxGroups = limit }}, + {"members", hardMaxMembers, func(value *Limits, limit int) { value.MaxMembers = limit }}, + {"members per group", hardMaxMembersPerGroup, func(value *Limits, limit int) { value.MaxMembersPerGroup = limit }}, + {"profiles", hardMaxProfiles, func(value *Limits, limit int) { value.MaxProfiles = limit }}, + {"scenario evaluations", hardMaxScenarioEvaluations, func(value *Limits, limit int) { value.MaxScenarioEvaluations = limit }}, + {"scenario evaluation text", hardMaxScenarioEvaluationBytes, func(value *Limits, limit int) { value.MaxScenarioEvaluationBytes = limit }}, + {"scenarios", hardMaxScenarios, func(value *Limits, limit int) { value.MaxScenarios = limit }}, + {"terms", hardMaxTerms, func(value *Limits, limit int) { value.MaxTerms = limit }}, + {"total text", hardMaxTotalTextBytes, func(value *Limits, limit int) { value.MaxTotalTextBytes = limit }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + exact := DefaultLimits() + test.set(&exact, test.hard) + if err := validateLimits(exact); err != nil { + t.Fatalf("hard limit rejected: %v", err) + } + for _, invalidValue := range []int{0, test.hard + 1} { + invalidLimits := DefaultLimits() + test.set(&invalidLimits, invalidValue) + if err := validateLimits(invalidLimits); ErrorCode(err) != "invalid_limit" { + t.Fatalf("limit %d ErrorCode() = %q, error = %v", invalidValue, ErrorCode(err), err) + } + } + }) + } +} + +func TestPreflightCardinalityBudgetsHaveExactTransitions(t *testing.T) { + tests := []struct { + name string + code string + prepare func(*Draft) int + set func(*Limits, int) + }{ + {"definitions", "definition_budget_exceeded", func(draft *Draft) int { + draft.NonClaimDefinitions = append(draft.NonClaimDefinitions, draft.NonClaimDefinitions[0]) + return len(draft.NonClaimDefinitions) + }, func(value *Limits, limit int) { value.MaxDefinitions = limit }}, + {"vocabulary", "vocabulary_budget_exceeded", func(draft *Draft) int { + draft.Vocabulary = append(draft.Vocabulary, draft.Vocabulary[0]) + return len(draft.Vocabulary) + }, func(value *Limits, limit int) { value.MaxTerms = limit }}, + {"derivations", "derivation_budget_exceeded", func(draft *Draft) int { + draft.Derivations = append(draft.Derivations, draft.Derivations[0]) + return len(draft.Derivations) + }, func(value *Limits, limit int) { value.MaxDerivations = limit }}, + {"profiles", "profile_budget_exceeded", func(draft *Draft) int { + draft.Profiles = append(draft.Profiles, draft.Profiles[0]) + return len(draft.Profiles) + }, func(value *Limits, limit int) { value.MaxProfiles = limit }}, + {"groups", "group_budget_exceeded", func(draft *Draft) int { + draft.Groups = append(draft.Groups, draft.Groups[0]) + return len(draft.Groups) + }, func(value *Limits, limit int) { value.MaxGroups = limit }}, + {"scenarios", "scenario_budget_exceeded", func(draft *Draft) int { + draft.Scenarios = append(draft.Scenarios, draft.Scenarios[0]) + return len(draft.Scenarios) + }, func(value *Limits, limit int) { value.MaxScenarios = limit }}, + {"members per group", "group_member_budget_exceeded", func(draft *Draft) int { + draft.Groups[0].Members = append(draft.Groups[0].Members, draft.Groups[0].Members[0]) + return len(draft.Groups[0].Members) + }, func(value *Limits, limit int) { value.MaxMembersPerGroup = limit }}, + {"members", "member_budget_exceeded", func(draft *Draft) int { + draft.Groups[0].Members = append(draft.Groups[0].Members, draft.Groups[0].Members[0]) + return totalMembers(*draft) + }, func(value *Limits, limit int) { value.MaxMembers = limit }}, + {"examples per scenario", "scenario_example_budget_exceeded", func(draft *Draft) int { + draft.Scenarios[0].Examples = append(draft.Scenarios[0].Examples, draft.Scenarios[0].Examples[0]) + return len(draft.Scenarios[0].Examples) + }, func(value *Limits, limit int) { value.MaxExamplesPerScenario = limit }}, + {"examples", "example_budget_exceeded", func(draft *Draft) int { + draft.Scenarios[0].Examples = append(draft.Scenarios[0].Examples, draft.Scenarios[0].Examples[0]) + return totalExamples(*draft) + }, func(value *Limits, limit int) { value.MaxExamples = limit }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + draft := validDraft() + actual := test.prepare(&draft) + if actual < 2 { + t.Fatalf("fixture cardinality = %d, want at least 2", actual) + } + + below := DefaultLimits() + test.set(&below, actual-1) + if err := preflight(draft, below); ErrorCode(err) != test.code { + t.Fatalf("limit-1 ErrorCode() = %q, error = %v, want %q", ErrorCode(err), err, test.code) + } + + exact := DefaultLimits() + test.set(&exact, actual) + if err := preflight(draft, exact); err != nil { + t.Fatalf("exact cardinality rejected before semantic validation: %v", err) + } + if _, err := NormalizeWithLimits(draft, exact); err == nil || ErrorCode(err) == test.code || ErrorCode(err) == "invalid_limit" { + t.Fatalf("exact cardinality did not reach later semantic validation: %v", err) + } + }) + } + + emptyGroups := validDraft() + emptyGroups.Groups = nil + if err := preflight(emptyGroups, DefaultLimits()); ErrorCode(err) != "group_budget_exceeded" { + t.Fatalf("empty groups ErrorCode() = %q, error = %v", ErrorCode(err), err) + } + emptyMembers := validDraft() + emptyMembers.Groups[0].Members = nil + if err := preflight(emptyMembers, DefaultLimits()); ErrorCode(err) != "group_member_budget_exceeded" { + t.Fatalf("empty members ErrorCode() = %q, error = %v", ErrorCode(err), err) + } +} + +func TestPreflightBudgetErrorPrecedence(t *testing.T) { + draft := precedenceDraft() + tests := []struct { + name string + limits Limits + code string + }{ + { + name: "limit admission before draft budgets", + limits: withLimits(func(value *Limits) { + value.MaxDefinitions = 0 + value.MaxTerms = 1 + }), + code: "invalid_limit", + }, + { + name: "definitions before vocabulary", + limits: withLimits(func(value *Limits) { + value.MaxDefinitions = 1 + value.MaxTerms = 1 + }), + code: "definition_budget_exceeded", + }, + { + name: "vocabulary before derivations", + limits: withLimits(func(value *Limits) { + value.MaxTerms = 1 + value.MaxDerivations = 1 + }), + code: "vocabulary_budget_exceeded", + }, + { + name: "derivations before profiles", + limits: withLimits(func(value *Limits) { + value.MaxDerivations = 1 + value.MaxProfiles = 1 + }), + code: "derivation_budget_exceeded", + }, + { + name: "profiles before groups", + limits: withLimits(func(value *Limits) { + value.MaxProfiles = 1 + value.MaxGroups = 1 + }), + code: "profile_budget_exceeded", + }, + { + name: "groups before scenarios", + limits: withLimits(func(value *Limits) { + value.MaxGroups = 1 + value.MaxScenarios = 1 + }), + code: "group_budget_exceeded", + }, + { + name: "scenarios before collection", + limits: withLimits(func(value *Limits) { + value.MaxScenarios = 1 + value.MaxCollectionItems = 1 + }), + code: "scenario_budget_exceeded", + }, + { + name: "collection before group members", + limits: withLimits(func(value *Limits) { + value.MaxCollectionItems = 1 + value.MaxMembersPerGroup = 1 + }), + code: "collection_item_budget_exceeded", + }, + { + name: "group members before total members", + limits: withLimits(func(value *Limits) { + value.MaxMembersPerGroup = 1 + value.MaxMembers = 1 + }), + code: "group_member_budget_exceeded", + }, + { + name: "total members before scenario examples", + limits: withLimits(func(value *Limits) { + value.MaxMembers = 1 + value.MaxExamplesPerScenario = 1 + }), + code: "member_budget_exceeded", + }, + { + name: "scenario examples before total examples", + limits: withLimits(func(value *Limits) { + value.MaxExamplesPerScenario = 1 + value.MaxExamples = 1 + }), + code: "scenario_example_budget_exceeded", + }, + { + name: "total examples before text", + limits: withLimits(func(value *Limits) { + value.MaxExamples = 1 + value.MaxTotalTextBytes = 1 + }), + code: "example_budget_exceeded", + }, + { + name: "text before scenario evaluation", + limits: withLimits(func(value *Limits) { + value.MaxTotalTextBytes = 1 + value.MaxScenarioEvaluations = 1 + }), + code: "text_budget_exceeded", + }, + { + name: "scenario evaluation items before text", + limits: withLimits(func(value *Limits) { + value.MaxScenarioEvaluations = 1 + value.MaxScenarioEvaluationBytes = 1 + }), + code: "scenario_evaluation_budget_exceeded", + }, + { + name: "scenario evaluation text before expansion", + limits: withLimits(func(value *Limits) { + value.MaxScenarioEvaluationBytes = 1 + value.MaxExpandedItems = 1 + }), + code: "scenario_evaluation_text_budget_exceeded", + }, + { + name: "expanded items before expanded text", + limits: withLimits(func(value *Limits) { + value.MaxExpandedItems = 1 + value.MaxExpandedTextBytes = 1 + }), + code: "expanded_item_budget_exceeded", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := NormalizeWithLimits(draft, test.limits) + if ErrorCode(err) != test.code { + t.Fatalf("ErrorCode() = %q, error = %v, want %q", ErrorCode(err), err, test.code) + } + }) + } +} + +func precedenceDraft() Draft { + draft := validDraft() + draft.NonClaimDefinitions = append(draft.NonClaimDefinitions, draft.NonClaimDefinitions[0]) + draft.Vocabulary = append(draft.Vocabulary, draft.Vocabulary[0]) + draft.Derivations = append(draft.Derivations, draft.Derivations[0]) + draft.Profiles = append(draft.Profiles, draft.Profiles[0]) + draft.Groups = append(draft.Groups, draft.Groups[0]) + draft.Scenarios = append(draft.Scenarios, draft.Scenarios[0]) + return draft +} + +func totalMembers(draft Draft) int { + total := 0 + for _, group := range draft.Groups { + total += len(group.Members) + } + return total +} + +func totalExamples(draft Draft) int { + total := 0 + for _, scenario := range draft.Scenarios { + total += len(scenario.Examples) + } + return total +} + +func observeInputCollectionItems(value reflect.Value) uint64 { + if !value.IsValid() { + return 0 + } + for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer { + if value.IsNil() { + return 0 + } + value = value.Elem() + } + switch value.Kind() { + case reflect.Struct: + if isPresenceField(value.Type()) { + if !value.FieldByName("Present").Bool() { + return 0 + } + return observeInputCollectionItems(value.FieldByName("Value")) + } + total := uint64(0) + for index := 0; index < value.NumField(); index++ { + total += observeInputCollectionItems(value.Field(index)) + } + return total + case reflect.Slice, reflect.Array: + total := uint64(value.Len()) + for index := 0; index < value.Len(); index++ { + total += observeInputCollectionItems(value.Index(index)) + } + return total + case reflect.Map: + total := uint64(value.Len()) + iterator := value.MapRange() + for iterator.Next() { + total += observeInputCollectionItems(iterator.Value()) + } + return total + default: + return 0 + } +} + +func withLimits(edit func(*Limits)) Limits { + value := DefaultLimits() + edit(&value) + return value +} diff --git a/internal/kernel/requirementsourcemodel/limits.go b/internal/kernel/requirementsourcemodel/limits.go new file mode 100644 index 0000000..e514a20 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/limits.go @@ -0,0 +1,302 @@ +package requirementsourcemodel + +const ( + hardMaxDefinitions = 4096 + hardMaxDerivations = 4096 + hardMaxExamples = 65536 + hardMaxExamplesPerScenario = 256 + hardMaxCollectionItems = 262144 + hardMaxExpandedItems = 1 << 20 + hardMaxExpandedTextBytes = 64 << 20 + hardMaxGroups = 4096 + hardMaxMembers = 16384 + hardMaxMembersPerGroup = 4096 + hardMaxProfiles = 2048 + hardMaxScenarioEvaluations = 1 << 20 + hardMaxScenarioEvaluationBytes = 64 << 20 + hardMaxScenarios = 8192 + hardMaxTerms = 4096 + hardMaxTotalTextBytes = 16 << 20 +) + +type Limits struct { + MaxDefinitions int + MaxDerivations int + MaxExamples int + MaxExamplesPerScenario int + MaxCollectionItems int + MaxExpandedItems int + MaxExpandedTextBytes int + MaxGroups int + MaxMembers int + MaxMembersPerGroup int + MaxProfiles int + MaxScenarioEvaluations int + MaxScenarioEvaluationBytes int + MaxScenarios int + MaxTerms int + MaxTotalTextBytes int +} + +func DefaultLimits() Limits { + return Limits{ + MaxDefinitions: hardMaxDefinitions, + MaxDerivations: hardMaxDerivations, + MaxExamples: hardMaxExamples, + MaxExamplesPerScenario: hardMaxExamplesPerScenario, + MaxCollectionItems: hardMaxCollectionItems, + MaxExpandedItems: hardMaxExpandedItems, + MaxExpandedTextBytes: hardMaxExpandedTextBytes, + MaxGroups: hardMaxGroups, + MaxMembers: hardMaxMembers, + MaxMembersPerGroup: hardMaxMembersPerGroup, + MaxProfiles: hardMaxProfiles, + MaxScenarioEvaluations: hardMaxScenarioEvaluations, + MaxScenarioEvaluationBytes: hardMaxScenarioEvaluationBytes, + MaxScenarios: hardMaxScenarios, + MaxTerms: hardMaxTerms, + MaxTotalTextBytes: hardMaxTotalTextBytes, + } +} + +func validateLimits(value Limits) error { + checks := []struct { + actual int + hard int + path string + }{ + {value.MaxDefinitions, hardMaxDefinitions, "limits.maxDefinitions"}, + {value.MaxDerivations, hardMaxDerivations, "limits.maxDerivations"}, + {value.MaxExamples, hardMaxExamples, "limits.maxExamples"}, + {value.MaxExamplesPerScenario, hardMaxExamplesPerScenario, "limits.maxExamplesPerScenario"}, + {value.MaxCollectionItems, hardMaxCollectionItems, "limits.maxCollectionItems"}, + {value.MaxExpandedItems, hardMaxExpandedItems, "limits.maxExpandedItems"}, + {value.MaxExpandedTextBytes, hardMaxExpandedTextBytes, "limits.maxExpandedTextBytes"}, + {value.MaxGroups, hardMaxGroups, "limits.maxGroups"}, + {value.MaxMembers, hardMaxMembers, "limits.maxMembers"}, + {value.MaxMembersPerGroup, hardMaxMembersPerGroup, "limits.maxMembersPerGroup"}, + {value.MaxProfiles, hardMaxProfiles, "limits.maxProfiles"}, + {value.MaxScenarioEvaluations, hardMaxScenarioEvaluations, "limits.maxScenarioEvaluations"}, + {value.MaxScenarioEvaluationBytes, hardMaxScenarioEvaluationBytes, "limits.maxScenarioEvaluationBytes"}, + {value.MaxScenarios, hardMaxScenarios, "limits.maxScenarios"}, + {value.MaxTerms, hardMaxTerms, "limits.maxTerms"}, + {value.MaxTotalTextBytes, hardMaxTotalTextBytes, "limits.maxTotalTextBytes"}, + } + for _, check := range checks { + if check.actual <= 0 || check.actual > check.hard { + return invalid("invalid_limit", check.path) + } + } + return nil +} + +func preflight(draft Draft, limits Limits) error { + if len(draft.NonClaimDefinitions) > limits.MaxDefinitions { + return invalid("definition_budget_exceeded", "nonClaimDefinitions") + } + if len(draft.Vocabulary) > limits.MaxTerms { + return invalid("vocabulary_budget_exceeded", "vocabulary") + } + if len(draft.Derivations) > limits.MaxDerivations { + return invalid("derivation_budget_exceeded", "derivations") + } + if len(draft.Profiles) > limits.MaxProfiles { + return invalid("profile_budget_exceeded", "profiles") + } + if len(draft.Groups) == 0 || len(draft.Groups) > limits.MaxGroups { + return invalid("group_budget_exceeded", "groups") + } + if len(draft.Scenarios) > limits.MaxScenarios { + return invalid("scenario_budget_exceeded", "scenarios") + } + if !collectionItemsWithinBudget(draft, limits.MaxCollectionItems) { + return invalid("collection_item_budget_exceeded", "draft") + } + + members := 0 + for index, group := range draft.Groups { + if len(group.Members) == 0 || len(group.Members) > limits.MaxMembersPerGroup { + return invalid("group_member_budget_exceeded", indexed("groups", index, "members")) + } + members += len(group.Members) + if members > limits.MaxMembers { + return invalid("member_budget_exceeded", "groups.members") + } + } + + examples := 0 + for index, scenario := range draft.Scenarios { + if len(scenario.Examples) > limits.MaxExamplesPerScenario { + return invalid("scenario_example_budget_exceeded", indexed("scenarios", index, "examples")) + } + examples += len(scenario.Examples) + if examples > limits.MaxExamples { + return invalid("example_budget_exceeded", "scenarios.examples") + } + } + + if draftTextBytes(draft) > uint64(limits.MaxTotalTextBytes) { + return invalid("text_budget_exceeded", "draft") + } + if err := preflightScenarioEvaluationBudget(draft, limits); err != nil { + return err + } + if err := preflightExpandedProjectionBudget(draft, limits); err != nil { + return err + } + return nil +} + +func collectionItemsWithinBudget(draft Draft, limit int) bool { + remaining := uint64(limit) + add := func(count int) bool { + value := uint64(count) + if value > remaining { + return false + } + remaining -= value + return true + } + if !add(len(draft.SourceNonClaimRefs)) || !add(len(draft.NonClaimDefinitions)) || !add(len(draft.Vocabulary)) || + !add(len(draft.Derivations)) || !add(len(draft.Profiles)) || !add(len(draft.Groups)) || !add(len(draft.Scenarios)) { + return false + } + for _, derivation := range draft.Derivations { + if !add(len(derivation.RequirementIDs)) || !add(len(derivation.NonClaimRefs)) { + return false + } + } + for _, profile := range draft.Profiles { + if !metadataCollectionsWithinBudget(profile.Fields, add) { + return false + } + } + for _, group := range draft.Groups { + if !add(len(group.SharedPremises)) || !add(len(group.Members)) { + return false + } + for _, member := range group.Members { + if !metadataCollectionsWithinBudget(member.Fields, add) { + return false + } + } + } + for _, scenario := range draft.Scenarios { + if !add(len(scenario.RequirementIDs)) || !add(len(scenario.Parameters)) || !add(len(scenario.Preconditions)) || + !add(len(scenario.ActionSequence)) || !add(len(scenario.ExpectedObservations)) || !add(len(scenario.ForbiddenObservations)) || + !add(len(scenario.Examples)) || !add(len(scenario.VocabularyRefs)) || !add(len(scenario.NonClaimRefs)) { + return false + } + for _, example := range scenario.Examples { + if !add(len(example.Values)) { + return false + } + } + } + return true +} + +func metadataCollectionsWithinBudget(fields MetadataFields, add func(int) bool) bool { + if fields.NonClaimRefs.Present && !add(len(fields.NonClaimRefs.Value)) { + return false + } + if fields.Lifecycle.Present && (!add(len(fields.Lifecycle.Value.ReplacementRequirementIDs)) || !add(len(fields.Lifecycle.Value.EvidenceRefs))) { + return false + } + return !fields.Deferral.Present || fields.Deferral.Value == nil || add(len(fields.Deferral.Value.EvidenceRefs)) +} + +func indexed(root string, index int, leaf string) string { + return root + "[" + decimal(index) + "]." + leaf +} + +func decimal(value int) string { + if value == 0 { + return "0" + } + buffer := [20]byte{} + position := len(buffer) + for value > 0 { + position-- + buffer[position] = byte('0' + value%10) + value /= 10 + } + return string(buffer[position:]) +} + +func draftTextBytes(draft Draft) uint64 { + total := textBytes(draft.SourceID, draft.SpecPackagePath) + total += stringsBytes(draft.SourceNonClaimRefs) + for _, definition := range draft.NonClaimDefinitions { + total += textBytes(definition.NonClaimID, definition.Statement) + } + for _, term := range draft.Vocabulary { + total += textBytes(term.TermID, string(term.Kind), term.Label, term.Definition) + } + for _, derivation := range draft.Derivations { + total += textBytes(derivation.DerivationID, string(derivation.SourceKind), string(derivation.SourceRef.ObjectFormat), derivation.SourceRef.CommitOID, derivation.SourceRef.Path, derivation.SourceRef.SHA256) + total += stringsBytes(derivation.RequirementIDs) + stringsBytes(derivation.NonClaimRefs) + } + for _, profile := range draft.Profiles { + total += uint64(len(profile.ProfileID)) + metadataTextBytes(profile.Fields) + } + for _, group := range draft.Groups { + total += textBytes(group.GroupID, group.ProfileID, group.StatementStem) + stringsBytes(group.SharedPremises) + for _, member := range group.Members { + total += textBytes(member.RequirementID, member.StatementCompletion) + metadataTextBytes(member.Fields) + } + } + for _, scenario := range draft.Scenarios { + total += uint64(len(scenario.ScenarioID)) + total += stringsBytes(scenario.RequirementIDs) + stringsBytes(scenario.Parameters) + stringsBytes(scenario.Preconditions) + total += stringsBytes(scenario.ActionSequence) + stringsBytes(scenario.ExpectedObservations) + stringsBytes(scenario.ForbiddenObservations) + total += stringsBytes(scenario.VocabularyRefs) + stringsBytes(scenario.NonClaimRefs) + for _, example := range scenario.Examples { + total += uint64(len(example.ExampleID)) + for key, scalar := range example.Values { + total += textBytes(key, string(scalar)) + } + } + } + return total +} + +func metadataTextBytes(fields MetadataFields) uint64 { + total := uint64(0) + if fields.OwnerID.Present { + total += uint64(len(fields.OwnerID.Value)) + } + if fields.ClaimLevel.Present { + total += uint64(len(fields.ClaimLevel.Value)) + } + if fields.RiskClass.Present { + total += uint64(len(fields.RiskClass.Value)) + } + if fields.NonClaimRefs.Present { + total += stringsBytes(fields.NonClaimRefs.Value) + } + if fields.Lifecycle.Present { + total += uint64(len(fields.Lifecycle.Value.State)) + stringsBytes(fields.Lifecycle.Value.ReplacementRequirementIDs) + stringsBytes(fields.Lifecycle.Value.EvidenceRefs) + } + if fields.Deferral.Present && fields.Deferral.Value != nil { + value := fields.Deferral.Value + total += textBytes(value.OwnerID, value.RiskAcceptedBy, value.ReviewCondition, value.ExpiryRef, value.MergePolicy) + total += stringsBytes(value.EvidenceRefs) + } + if fields.UpdatePolicy.Present { + total += uint64(len(fields.UpdatePolicy.Value.ReviewOwnerID)) + } + return total +} + +func textBytes(values ...string) uint64 { + return stringsBytes(values) +} + +func stringsBytes(values []string) uint64 { + total := uint64(0) + for _, value := range values { + total += uint64(len(value)) + } + return total +} diff --git a/internal/kernel/requirementsourcemodel/metadata.go b/internal/kernel/requirementsourcemodel/metadata.go new file mode 100644 index 0000000..efa7893 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/metadata.go @@ -0,0 +1,224 @@ +package requirementsourcemodel + +import "reflect" + +var metadataFieldIDs = []MetadataFieldID{ + "claimLevel", + "deferral", + "lifecycle", + "nonClaimRefs", + "ownerId", + "riskClass", + "updatePolicy", +} + +func normalizeMetadataFields(value MetadataFields, path string) (MetadataFields, error) { + if err := rejectHiddenMetadataValues(value, path); err != nil { + return MetadataFields{}, err + } + result := value + var err error + if value.OwnerID.Present { + result.OwnerID.Value, err = canonicalExternalID(value.OwnerID.Value, path+".ownerId") + if err != nil { + return MetadataFields{}, err + } + } + if value.ClaimLevel.Present { + if err := validClaimLevel(value.ClaimLevel.Value, path+".claimLevel"); err != nil { + return MetadataFields{}, err + } + } + if value.RiskClass.Present { + if err := validRiskClass(value.RiskClass.Value, path+".riskClass"); err != nil { + return MetadataFields{}, err + } + } + if value.NonClaimRefs.Present { + result.NonClaimRefs.Value, err = normalizeIDs(value.NonClaimRefs.Value, "NCL-", path+".nonClaimRefs", true) + if err != nil { + return MetadataFields{}, err + } + } + if value.Lifecycle.Present { + result.Lifecycle.Value, err = normalizeLifecycle(value.Lifecycle.Value, path+".lifecycle") + if err != nil { + return MetadataFields{}, err + } + } + if value.Deferral.Present && value.Deferral.Value != nil { + result.Deferral.Value, err = normalizeDeferral(value.Deferral.Value, path+".deferral") + if err != nil { + return MetadataFields{}, err + } + } + if value.UpdatePolicy.Present { + result.UpdatePolicy.Value, err = normalizeUpdatePolicy(value.UpdatePolicy.Value, path+".updatePolicy") + if err != nil { + return MetadataFields{}, err + } + } + return result, nil +} + +func rejectHiddenMetadataValues(value MetadataFields, path string) error { + checks := []struct { + present bool + value any + zero any + field string + }{ + {value.OwnerID.Present, value.OwnerID.Value, "", "ownerId"}, + {value.ClaimLevel.Present, value.ClaimLevel.Value, ClaimLevel(""), "claimLevel"}, + {value.RiskClass.Present, value.RiskClass.Value, RiskClass(""), "riskClass"}, + {value.NonClaimRefs.Present, value.NonClaimRefs.Value, []string(nil), "nonClaimRefs"}, + {value.Lifecycle.Present, value.Lifecycle.Value, Lifecycle{}, "lifecycle"}, + {value.Deferral.Present, value.Deferral.Value, (*Deferral)(nil), "deferral"}, + {value.UpdatePolicy.Present, value.UpdatePolicy.Value, UpdatePolicy{}, "updatePolicy"}, + } + for _, check := range checks { + if !check.present && !reflect.DeepEqual(check.value, check.zero) { + return invalid("hidden_field_payload", path+"."+check.field) + } + } + return nil +} + +func normalizeLifecycle(value Lifecycle, path string) (Lifecycle, error) { + if err := validLifecycleState(value.State, path+".state"); err != nil { + return Lifecycle{}, err + } + replacements, err := normalizeIDs(value.ReplacementRequirementIDs, "REQ-", path+".replacementRequirementIds", true) + if err != nil { + return Lifecycle{}, err + } + evidence, err := normalizePaths(value.EvidenceRefs, path+".evidenceRefs", true) + if err != nil { + return Lifecycle{}, err + } + return Lifecycle{State: value.State, ReplacementRequirementIDs: replacements, EvidenceRefs: evidence}, nil +} + +func normalizeDeferral(value *Deferral, path string) (*Deferral, error) { + ownerID, err := canonicalExternalID(value.OwnerID, path+".ownerId") + if err != nil { + return nil, err + } + riskAcceptedBy, err := canonicalExternalID(value.RiskAcceptedBy, path+".riskAcceptedBy") + if err != nil { + return nil, err + } + reviewCondition, err := canonicalText(value.ReviewCondition, path+".reviewCondition", false, true) + if err != nil { + return nil, err + } + expiryRef, err := canonicalExternalID(value.ExpiryRef, path+".expiryRef") + if err != nil { + return nil, err + } + mergePolicy, err := canonicalExternalID(value.MergePolicy, path+".mergePolicy") + if err != nil { + return nil, err + } + evidence, err := normalizePaths(value.EvidenceRefs, path+".evidenceRefs", false) + if err != nil { + return nil, err + } + return &Deferral{ + OwnerID: ownerID, + RiskAcceptedBy: riskAcceptedBy, + ReviewCondition: reviewCondition, + ExpiryRef: expiryRef, + MergePolicy: mergePolicy, + EvidenceRefs: evidence, + }, nil +} + +func normalizeUpdatePolicy(value UpdatePolicy, path string) (UpdatePolicy, error) { + reviewOwnerID, err := canonicalExternalID(value.ReviewOwnerID, path+".reviewOwnerId") + if err != nil { + return UpdatePolicy{}, err + } + value.ReviewOwnerID = reviewOwnerID + return value, nil +} + +func metadataFieldCount(value MetadataFields) int { + count := 0 + for _, present := range metadataPresence(value) { + if present { + count++ + } + } + return count +} + +func metadataPresence(value MetadataFields) map[MetadataFieldID]bool { + return map[MetadataFieldID]bool{ + "ownerId": value.OwnerID.Present, + "claimLevel": value.ClaimLevel.Present, + "riskClass": value.RiskClass.Present, + "nonClaimRefs": value.NonClaimRefs.Present, + "lifecycle": value.Lifecycle.Present, + "deferral": value.Deferral.Present, + "updatePolicy": value.UpdatePolicy.Present, + } +} + +func resolveMetadata(profileID string, memberID string, profile MetadataFields, member MetadataFields, path string) (AtomicRequirement, []FieldOwner, error) { + profilePresence := metadataPresence(profile) + memberPresence := metadataPresence(member) + for _, fieldID := range metadataFieldIDs { + if profilePresence[fieldID] == memberPresence[fieldID] { + return AtomicRequirement{}, nil, invalid("metadata_partition_violation", path+"."+string(fieldID)) + } + } + owners := make([]FieldOwner, 0, len(metadataFieldIDs)) + ownerFor := func(fieldID MetadataFieldID) (MetadataOwnerKind, string) { + if profilePresence[fieldID] { + return MetadataOwnerProfile, profileID + } + return MetadataOwnerMember, memberID + } + for _, fieldID := range metadataFieldIDs { + ownerKind, ownerID := ownerFor(fieldID) + owners = append(owners, FieldOwner{FieldID: fieldID, OwnerKind: ownerKind, OwnerID: ownerID}) + } + result := AtomicRequirement{} + if profile.OwnerID.Present { + result.OwnerID = profile.OwnerID.Value + } else { + result.OwnerID = member.OwnerID.Value + } + if profile.ClaimLevel.Present { + result.ClaimLevel = profile.ClaimLevel.Value + } else { + result.ClaimLevel = member.ClaimLevel.Value + } + if profile.RiskClass.Present { + result.RiskClass = profile.RiskClass.Value + } else { + result.RiskClass = member.RiskClass.Value + } + if profile.NonClaimRefs.Present { + result.NonClaimRefs = cloneStrings(profile.NonClaimRefs.Value) + } else { + result.NonClaimRefs = cloneStrings(member.NonClaimRefs.Value) + } + if profile.Lifecycle.Present { + result.Lifecycle = cloneLifecycle(profile.Lifecycle.Value) + } else { + result.Lifecycle = cloneLifecycle(member.Lifecycle.Value) + } + if profile.Deferral.Present { + result.Deferral = cloneDeferral(profile.Deferral.Value) + } else { + result.Deferral = cloneDeferral(member.Deferral.Value) + } + if profile.UpdatePolicy.Present { + result.UpdatePolicy = profile.UpdatePolicy.Value + } else { + result.UpdatePolicy = member.UpdatePolicy.Value + } + return result, owners, nil +} diff --git a/internal/kernel/requirementsourcemodel/metadata_ownership_test.go b/internal/kernel/requirementsourcemodel/metadata_ownership_test.go new file mode 100644 index 0000000..3cb99e0 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/metadata_ownership_test.go @@ -0,0 +1,258 @@ +package requirementsourcemodel + +import ( + "reflect" + "testing" +) + +var independentlyExpectedMetadataFieldIDs = []MetadataFieldID{ + "claimLevel", + "deferral", + "lifecycle", + "nonClaimRefs", + "ownerId", + "riskClass", + "updatePolicy", +} + +func TestNormalizeRejectsOverlapAndGapForEveryMetadataField(t *testing.T) { + if !reflect.DeepEqual(metadataFieldIDs, independentlyExpectedMetadataFieldIDs) { + t.Fatalf("production metadata field inventory = %v, independent inventory = %v", metadataFieldIDs, independentlyExpectedMetadataFieldIDs) + } + for _, fieldID := range independentlyExpectedMetadataFieldIDs { + t.Run(string(fieldID)+" overlap", func(t *testing.T) { + draft := validDraft() + profile := &draft.Profiles[0].Fields + member := &draft.Groups[0].Members[0].Fields + if metadataPresence(*profile)[fieldID] { + setMetadataField(member, fieldID, *profile) + } else { + setMetadataField(profile, fieldID, *member) + } + _, err := Normalize(draft) + if ErrorCode(err) != "metadata_partition_violation" { + t.Fatalf("ErrorCode() = %q, error = %v", ErrorCode(err), err) + } + }) + t.Run(string(fieldID)+" gap", func(t *testing.T) { + draft := validDraft() + profile := &draft.Profiles[0].Fields + member := &draft.Groups[0].Members[0].Fields + if metadataPresence(*profile)[fieldID] { + clearMetadataField(profile, fieldID) + } else { + clearMetadataField(member, fieldID) + } + _, err := Normalize(draft) + if ErrorCode(err) != "metadata_partition_violation" { + t.Fatalf("ErrorCode() = %q, error = %v", ErrorCode(err), err) + } + }) + } +} + +func TestNormalizePreservesExactMetadataOwnershipMatrix(t *testing.T) { + for _, fieldID := range metadataFieldIDs { + t.Run(string(fieldID), func(t *testing.T) { + baseline, err := Normalize(validDraft()) + if err != nil { + t.Fatal(err) + } + assertEveryOriginHasResolvingFieldOwners(t, baseline.Layout()) + movedDraft := validDraft() + moveMetadataFieldToOtherOwner(&movedDraft, fieldID) + moved, err := Normalize(movedDraft) + if err != nil { + t.Fatal(err) + } + assertEveryOriginHasResolvingFieldOwners(t, moved.Layout()) + if !reflect.DeepEqual(baseline.Atomic(), moved.Atomic()) { + t.Fatal("moving metadata ownership changed atomic semantics") + } + if !reflect.DeepEqual(baseline.References(), moved.References()) { + t.Fatal("moving metadata ownership changed reference semantics") + } + baselineOwners := ownersByRequirement(baseline.Layout()) + movedOwners := ownersByRequirement(moved.Layout()) + for _, requirementID := range []string{"REQ-MODEL-001", "REQ-MODEL-002"} { + before := baselineOwners[requirementID] + after := movedOwners[requirementID] + if len(before) != len(metadataFieldIDs) || len(after) != len(metadataFieldIDs) { + t.Fatalf("owner cardinality before=%d after=%d", len(before), len(after)) + } + for _, candidate := range metadataFieldIDs { + left, leftExists := before[candidate] + right, rightExists := after[candidate] + if !leftExists || !rightExists { + t.Fatalf("field owner %q is missing", candidate) + } + if candidate == fieldID { + if left.OwnerKind == right.OwnerKind || !ownerIDMatches(left, requirementID, baseline.Layout()) || !ownerIDMatches(right, requirementID, moved.Layout()) { + t.Fatalf("field %q owner transition = %#v -> %#v", fieldID, left, right) + } + continue + } + if !reflect.DeepEqual(left, right) { + t.Fatalf("moving %q changed unrelated owner %q: %#v -> %#v", fieldID, candidate, left, right) + } + } + } + }) + } +} + +func TestNormalizeClosesEveryOriginOwnerReference(t *testing.T) { + model, err := Normalize(validDraft()) + if err != nil { + t.Fatal(err) + } + assertEveryOriginHasResolvingFieldOwners(t, model.Layout()) +} + +func assertEveryOriginHasResolvingFieldOwners(t *testing.T, layout LayoutProjection) { + t.Helper() + profiles := make(map[string]struct{}, len(layout.Profiles)) + for _, profile := range layout.Profiles { + profiles[profile.ProfileID] = struct{}{} + } + members := map[string]struct{}{} + for _, group := range layout.Groups { + for _, member := range group.Members { + members[member.RequirementID] = struct{}{} + } + } + origins := make(map[string]struct{}, len(layout.Origins)) + profileless := 0 + for _, origin := range layout.Origins { + if _, duplicate := origins[origin.RequirementID]; duplicate { + t.Fatalf("duplicate origin for %q", origin.RequirementID) + } + origins[origin.RequirementID] = struct{}{} + if _, exists := members[origin.RequirementID]; !exists { + t.Fatalf("origin %q has no layout member", origin.RequirementID) + } + if origin.ProfileID == "" { + profileless++ + } else if _, exists := profiles[origin.ProfileID]; !exists { + t.Fatalf("origin %q has missing profile %q", origin.RequirementID, origin.ProfileID) + } + + seenFields := make(map[MetadataFieldID]struct{}, len(origin.FieldOwners)) + for _, owner := range origin.FieldOwners { + if _, duplicate := seenFields[owner.FieldID]; duplicate { + t.Fatalf("origin %q has duplicate owner for %q", origin.RequirementID, owner.FieldID) + } + seenFields[owner.FieldID] = struct{}{} + switch owner.OwnerKind { + case MetadataOwnerMember: + if owner.OwnerID != origin.RequirementID { + t.Fatalf("origin %q member owner resolves to %q", origin.RequirementID, owner.OwnerID) + } + case MetadataOwnerProfile: + if origin.ProfileID == "" || owner.OwnerID != origin.ProfileID { + t.Fatalf("origin %q profile owner resolves to %q with profile %q", origin.RequirementID, owner.OwnerID, origin.ProfileID) + } + if _, exists := profiles[owner.OwnerID]; !exists { + t.Fatalf("origin %q owner profile %q is missing", origin.RequirementID, owner.OwnerID) + } + default: + t.Fatalf("origin %q has unknown owner kind %q", origin.RequirementID, owner.OwnerKind) + } + } + if len(seenFields) != len(metadataFieldIDs) { + t.Fatalf("origin %q owner cardinality = %d, want %d", origin.RequirementID, len(seenFields), len(metadataFieldIDs)) + } + for _, fieldID := range metadataFieldIDs { + if _, exists := seenFields[fieldID]; !exists { + t.Fatalf("origin %q is missing owner for %q", origin.RequirementID, fieldID) + } + } + } + if profileless == 0 { + t.Fatal("ownership closure fixture has no profile-less origin") + } + if !reflect.DeepEqual(origins, members) { + t.Fatalf("origin/member closure differs: origins=%v members=%v", origins, members) + } +} + +func moveMetadataFieldToOtherOwner(draft *Draft, fieldID MetadataFieldID) { + profile := &draft.Profiles[0].Fields + members := draft.Groups[0].Members + if metadataPresence(*profile)[fieldID] { + source := cloneMetadataFields(*profile) + for index := range members { + setMetadataField(&members[index].Fields, fieldID, source) + } + clearMetadataField(profile, fieldID) + return + } + source := cloneMetadataFields(members[0].Fields) + setMetadataField(profile, fieldID, source) + for index := range members { + clearMetadataField(&members[index].Fields, fieldID) + } +} + +func setMetadataField(target *MetadataFields, fieldID MetadataFieldID, source MetadataFields) { + value := cloneMetadataFields(source) + switch fieldID { + case "ownerId": + target.OwnerID = value.OwnerID + case "claimLevel": + target.ClaimLevel = value.ClaimLevel + case "riskClass": + target.RiskClass = value.RiskClass + case "nonClaimRefs": + target.NonClaimRefs = value.NonClaimRefs + case "lifecycle": + target.Lifecycle = value.Lifecycle + case "deferral": + target.Deferral = value.Deferral + case "updatePolicy": + target.UpdatePolicy = value.UpdatePolicy + } +} + +func clearMetadataField(target *MetadataFields, fieldID MetadataFieldID) { + switch fieldID { + case "ownerId": + target.OwnerID = Field[string]{} + case "claimLevel": + target.ClaimLevel = Field[ClaimLevel]{} + case "riskClass": + target.RiskClass = Field[RiskClass]{} + case "nonClaimRefs": + target.NonClaimRefs = Field[[]string]{} + case "lifecycle": + target.Lifecycle = Field[Lifecycle]{} + case "deferral": + target.Deferral = Field[*Deferral]{} + case "updatePolicy": + target.UpdatePolicy = Field[UpdatePolicy]{} + } +} + +func ownersByRequirement(layout LayoutProjection) map[string]map[MetadataFieldID]FieldOwner { + result := map[string]map[MetadataFieldID]FieldOwner{} + for _, origin := range layout.Origins { + owners := map[MetadataFieldID]FieldOwner{} + for _, owner := range origin.FieldOwners { + owners[owner.FieldID] = owner + } + result[origin.RequirementID] = owners + } + return result +} + +func ownerIDMatches(owner FieldOwner, requirementID string, layout LayoutProjection) bool { + if owner.OwnerKind == MetadataOwnerMember { + return owner.OwnerID == requirementID + } + for _, origin := range layout.Origins { + if origin.RequirementID == requirementID { + return owner.OwnerKind == MetadataOwnerProfile && owner.OwnerID == origin.ProfileID && origin.ProfileID != "" + } + } + return false +} diff --git a/internal/kernel/requirementsourcemodel/model_test.go b/internal/kernel/requirementsourcemodel/model_test.go new file mode 100644 index 0000000..7363f2e --- /dev/null +++ b/internal/kernel/requirementsourcemodel/model_test.go @@ -0,0 +1,465 @@ +package requirementsourcemodel + +import ( + "reflect" + "strings" + "testing" +) + +func TestNormalizeBuildsDeterministicSeparatedProjections(t *testing.T) { + draft := validDraft() + model, err := Normalize(draft) + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + atomic := model.Atomic() + if len(atomic.Requirements) != 4 { + t.Fatalf("requirement count = %d, want 4", len(atomic.Requirements)) + } + if got := atomic.Requirements[0].Invariant; got != "The service must accept requests." { + t.Fatalf("first invariant = %q", got) + } + if got := atomic.Requirements[0].SharedPremises; !reflect.DeepEqual(got, []string{"The service is available."}) { + t.Fatalf("shared premises = %#v", got) + } + + permuted := validDraft() + reverseDefinitions(permuted.NonClaimDefinitions) + reverseVocabulary(permuted.Vocabulary) + reverseDerivations(permuted.Derivations) + reverseProfiles(permuted.Profiles) + reverseGroups(permuted.Groups) + reverseScenarios(permuted.Scenarios) + permuted.Groups[2].Members[0].Fields.NonClaimRefs.Value = []string{"NCL-MODEL-002"} + other, err := Normalize(permuted) + if err != nil { + t.Fatalf("Normalize(permuted) error = %v", err) + } + if !reflect.DeepEqual(model.Atomic(), other.Atomic()) || + !reflect.DeepEqual(model.Layout(), other.Layout()) || + !reflect.DeepEqual(model.References(), other.References()) { + t.Fatal("set-like input permutation changed normalized projections") + } +} + +func TestNormalizeSeparatesAuthoringLayoutFromAtomicSemantics(t *testing.T) { + baseline, err := Normalize(validDraft()) + if err != nil { + t.Fatal(err) + } + changedDraft := validDraft() + changedDraft.Groups[0].GroupID = "RGRP-MODEL-RENAMED" + changed, err := Normalize(changedDraft) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(baseline.Atomic(), changed.Atomic()) { + t.Fatal("group rename changed atomic semantics") + } + if reflect.DeepEqual(baseline.Layout(), changed.Layout()) { + t.Fatal("group rename did not change layout projection") + } + if reflect.DeepEqual(baseline.References(), changed.References()) { + t.Fatal("group rename did not change reference projection") + } +} + +func TestNormalizeBuildsEveryTypedReferenceRole(t *testing.T) { + model, err := Normalize(validDraft()) + if err != nil { + t.Fatal(err) + } + expected := map[ReferenceKind]struct { + from EntityKind + to EntityKind + }{ + ReferenceDerivationNonClaim: {EntityDerivation, EntityNonClaim}, + ReferenceDerivationRequirement: {EntityDerivation, EntityRequirement}, + ReferenceGroupMember: {EntityGroup, EntityRequirement}, + ReferenceGroupProfile: {EntityGroup, EntityProfile}, + ReferenceLifecycleReplacement: {EntityRequirement, EntityRequirement}, + ReferenceRequirementNonClaim: {EntityRequirement, EntityNonClaim}, + ReferenceScenarioNonClaim: {EntityScenario, EntityNonClaim}, + ReferenceScenarioRequirement: {EntityScenario, EntityRequirement}, + ReferenceScenarioVocabulary: {EntityScenario, EntityTerm}, + ReferenceSourceNonClaim: {EntitySource, EntityNonClaim}, + } + seen := map[ReferenceKind]bool{} + for _, edge := range model.References().Edges { + roles, exists := expected[edge.Kind] + if !exists { + t.Fatalf("unknown reference kind %q", edge.Kind) + } + if edge.From.Kind != roles.from || edge.To.Kind != roles.to { + t.Fatalf("reference %q has roles %q -> %q, want %q -> %q", edge.Kind, edge.From.Kind, edge.To.Kind, roles.from, roles.to) + } + seen[edge.Kind] = true + } + for kind := range expected { + if !seen[kind] { + t.Fatalf("reference kind %q is not exercised", kind) + } + } +} + +func TestModelAccessorsDoNotExposeMutableOwnerState(t *testing.T) { + model, err := Normalize(validDraft()) + if err != nil { + t.Fatal(err) + } + assertAccessorReturnsDetachedState(t, "atomic", model.Atomic) + assertAccessorReturnsDetachedState(t, "layout", model.Layout) + assertAccessorReturnsDetachedState(t, "references", model.References) +} + +func TestNormalizeRejectsInvalidReferenceAndMetadataPartitions(t *testing.T) { + tests := []struct { + name string + code string + edit func(*Draft) + }{ + { + name: "dangling nonclaim", + code: "dangling_nonclaim_ref", + edit: func(draft *Draft) { draft.SourceNonClaimRefs[0] = "NCL-MISSING" }, + }, + { + name: "unreferenced vocabulary", + code: "unreferenced_vocabulary", + edit: func(draft *Draft) { draft.Scenarios[0].VocabularyRefs = nil }, + }, + { + name: "dangling scenario requirement", + code: "dangling_requirement_ref", + edit: func(draft *Draft) { draft.Scenarios[0].RequirementIDs[0] = "REQ-MISSING" }, + }, + { + name: "profile member overlap", + code: "metadata_partition_violation", + edit: func(draft *Draft) { draft.Groups[0].Members[0].Fields.OwnerID = Own("owner.duplicate") }, + }, + { + name: "profile member gap", + code: "metadata_partition_violation", + edit: func(draft *Draft) { draft.Groups[0].Members[0].Fields.NonClaimRefs = Field[[]string]{} }, + }, + { + name: "hidden omitted payload", + code: "hidden_field_payload", + edit: func(draft *Draft) { + draft.Groups[0].Members[0].Fields.OwnerID = Field[string]{Value: "owner.hidden"} + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + draft := validDraft() + test.edit(&draft) + _, err := Normalize(draft) + if ErrorCode(err) != test.code { + t.Fatalf("ErrorCode() = %q, error = %v, want %q", ErrorCode(err), err, test.code) + } + }) + } +} + +func TestNormalizeRejectsBudgetBeforeMemberSemantics(t *testing.T) { + draft := validDraft() + draft.Groups[0].Members[1].StatementCompletion = "TODO" + limits := DefaultLimits() + limits.MaxMembers = 1 + _, err := NormalizeWithLimits(draft, limits) + if ErrorCode(err) != "member_budget_exceeded" { + t.Fatalf("ErrorCode() = %q, error = %v", ErrorCode(err), err) + } +} + +func TestNormalizeRejectsNestedCollectionBudgetBeforeElementSemantics(t *testing.T) { + draft := validDraft() + limits := DefaultLimits() + baselineItems := observeInputCollectionItems(reflect.ValueOf(draft)) + limits.MaxCollectionItems = int(baselineItems) + draft.Scenarios[0].Preconditions = append(draft.Scenarios[0].Preconditions, "TODO") + _, err := NormalizeWithLimits(draft, limits) + if ErrorCode(err) != "collection_item_budget_exceeded" { + t.Fatalf("ErrorCode() = %q, error = %v", ErrorCode(err), err) + } +} + +func TestNormalizeEnforcesExactInputTextBudgetBeforeElementSemantics(t *testing.T) { + draft := validDraft() + limits := DefaultLimits() + observedBytes := observeStructuredCost(draft).TextBytes + if draftTextBytes(draft) != observedBytes { + t.Fatalf("production input text bytes = %d, observed = %d", draftTextBytes(draft), observedBytes) + } + limits.MaxTotalTextBytes = int(observedBytes) + if _, err := NormalizeWithLimits(draft, limits); err != nil { + t.Fatalf("exact input text budget rejected: %v", err) + } + + invalidDraft := validDraft() + invalidDraft.Groups[0].Members[0].StatementCompletion = "TODO" + invalidBytes := int(observeStructuredCost(invalidDraft).TextBytes) + limits.MaxTotalTextBytes = invalidBytes - 1 + _, err := NormalizeWithLimits(invalidDraft, limits) + if ErrorCode(err) != "text_budget_exceeded" { + t.Fatalf("limit-1 ErrorCode() = %q, error = %v", ErrorCode(err), err) + } + limits.MaxTotalTextBytes = invalidBytes + _, err = NormalizeWithLimits(invalidDraft, limits) + if ErrorCode(err) != "placeholder_text" { + t.Fatalf("exact-limit ErrorCode() = %q, error = %v", ErrorCode(err), err) + } +} + +func TestNormalizeRejectsExpandedProjectionBudgetsBeforeMemberSemantics(t *testing.T) { + tests := []struct { + name string + code string + edit func(*Draft, *Limits) + }{ + { + name: "text amplification", + code: "expanded_text_budget_exceeded", + edit: func(draft *Draft, limits *Limits) { + draft.Groups[0].StatementStem = strings.Repeat("A", 4096) + draft.Groups[0].Members = expandedMembers(draft.Groups[0].Members[0], 512) + limits.MaxExpandedTextBytes = 1 << 20 + }, + }, + { + name: "item amplification", + code: "expanded_item_budget_exceeded", + edit: func(draft *Draft, limits *Limits) { + draft.Groups[0].Members = expandedMembers(draft.Groups[0].Members[0], 100) + draft.Groups[0].SharedPremises = make([]string, 20) + for index := range draft.Groups[0].SharedPremises { + draft.Groups[0].SharedPremises[index] = "Premise " + decimal(index+1) + "." + } + limits.MaxExpandedItems = 1000 + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + draft := validDraft() + limits := DefaultLimits() + test.edit(&draft, &limits) + draft.Groups[0].Members[len(draft.Groups[0].Members)-1].StatementCompletion = "TODO" + _, err := NormalizeWithLimits(draft, limits) + if ErrorCode(err) != test.code { + t.Fatalf("ErrorCode() = %q, error = %v, want %q", ErrorCode(err), err, test.code) + } + }) + } +} + +func TestNormalizeRejectsContradictoryScenarioObservations(t *testing.T) { + draft := validDraft() + draft.Scenarios[0].ForbiddenObservations = []string{"The request is accepted."} + _, err := Normalize(draft) + if ErrorCode(err) != "contradictory_observation" { + t.Fatalf("ErrorCode() = %q, error = %v", ErrorCode(err), err) + } +} + +func TestNormalizeReportsUnreferencedDefinitionsDeterministically(t *testing.T) { + draft := validDraft() + draft.NonClaimDefinitions = append(draft.NonClaimDefinitions, + NonClaimDefinition{NonClaimID: "NCL-MODEL-006", Statement: "This sixth declaration remains deliberately unreferenced."}, + NonClaimDefinition{NonClaimID: "NCL-MODEL-005", Statement: "This fifth declaration remains deliberately unreferenced."}, + ) + const expected = "unreferenced_definition: nonClaimDefinitions.NCL-MODEL-005" + for attempt := 0; attempt < 100; attempt++ { + _, err := Normalize(draft) + if err == nil || err.Error() != expected { + t.Fatalf("attempt %d error = %v, want %q", attempt, err, expected) + } + } +} + +func expandedMembers(template Member, count int) []Member { + result := make([]Member, count) + for index := range result { + requirementID := "REQ-MODEL-EXPANDED-" + decimal(index+1) + if index == 0 { + requirementID = "REQ-MODEL-001" + } else if index == 1 { + requirementID = "REQ-MODEL-002" + } + result[index] = Member{ + RequirementID: requirementID, + StatementCompletion: "accept request " + decimal(index+1) + ".", + Fields: cloneMetadataFields(template.Fields), + } + } + return result +} + +func TestNormalizeDiagnosticsDoNotEchoCallerText(t *testing.T) { + const sentinel = "ghp_0123456789abcdefghijklmnopqrstuvwxyz" + draft := validDraft() + draft.NonClaimDefinitions[0].Statement = "token=" + sentinel + _, err := Normalize(draft) + if err == nil { + t.Fatal("secret-shaped caller text was accepted") + } + if strings.Contains(err.Error(), sentinel) { + t.Fatal("validation error disclosed caller-owned text") + } + if ErrorCode(err) != "invalid_text" { + t.Fatalf("ErrorCode() = %q, error = %v", ErrorCode(err), err) + } +} + +func validDraft() Draft { + active := Lifecycle{State: LifecycleActive} + blockingProfile := Profile{ + ProfileID: "RPROF-MODEL-BLOCKING", + Fields: MetadataFields{ + OwnerID: Own("proofkit.model"), + ClaimLevel: Own(ClaimBlocking), + RiskClass: Own(RiskHigh), + UpdatePolicy: Own(UpdatePolicy{ + ReviewOwnerID: "proofkit.model", + RequiresImpactDeclaration: true, + RequiresProofBindingReview: true, + }), + }, + } + memberFields := func() MetadataFields { + return MetadataFields{ + NonClaimRefs: Own([]string{"NCL-MODEL-002"}), + Lifecycle: Own(active), + Deferral: Own[*Deferral](nil), + } + } + fullFields := func(claim ClaimLevel, lifecycle Lifecycle, deferral *Deferral) MetadataFields { + return MetadataFields{ + OwnerID: Own("proofkit.model"), + ClaimLevel: Own(claim), + RiskClass: Own(RiskMedium), + NonClaimRefs: Own([]string{"NCL-MODEL-002"}), + Lifecycle: Own(lifecycle), + Deferral: Own(deferral), + UpdatePolicy: Own(UpdatePolicy{ + ReviewOwnerID: "proofkit.model", + RequiresImpactDeclaration: true, + RequiresProofBindingReview: true, + }), + } + } + deferral := &Deferral{ + OwnerID: "proofkit.model", + RiskAcceptedBy: "proofkit.owner", + ReviewCondition: "Review after the model experiment.", + ExpiryRef: "proofkit.model.expiry", + MergePolicy: "proofkit.model.merge", + EvidenceRefs: []string{"docs/evidence/deferral.md"}, + } + return Draft{ + SourceID: "proofkit.model.source", + SpecPackagePath: "docs/specs/proofkit-model", + SourceNonClaimRefs: []string{"NCL-MODEL-001"}, + NonClaimDefinitions: []NonClaimDefinition{ + {NonClaimID: "NCL-MODEL-001", Statement: "The model does not prove implementation correctness."}, + {NonClaimID: "NCL-MODEL-002", Statement: "A declared requirement does not prove its own satisfaction."}, + {NonClaimID: "NCL-MODEL-003", Statement: "Scenario examples are not exhaustive proof."}, + {NonClaimID: "NCL-MODEL-004", Statement: "Derivation provenance does not prove requirement correctness."}, + }, + Vocabulary: []VocabularyTerm{ + {TermID: "TERM-MODEL-SERVICE", Kind: TermSubject, Label: "service", Definition: "The bounded service under specification."}, + }, + Derivations: []Derivation{ + { + DerivationID: "DRV-MODEL-001", + SourceKind: SourceOwnerDecision, + SourceRef: GitBlobRef{ + ObjectFormat: ObjectSHA1, + CommitOID: "0123456789abcdef0123456789abcdef01234567", + Path: "docs/decisions/model.md", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, + Selector: ByteRange{Start: 0, End: 64}, + RequirementIDs: []string{"REQ-MODEL-001", "REQ-MODEL-002"}, + NonClaimRefs: []string{"NCL-MODEL-004"}, + }, + }, + Profiles: []Profile{blockingProfile}, + Groups: []Group{ + { + GroupID: "RGRP-MODEL-REQUESTS", + ProfileID: "RPROF-MODEL-BLOCKING", + StatementStem: "The service must", + SharedPremises: []string{"The service is available."}, + Members: []Member{ + {RequirementID: "REQ-MODEL-001", StatementCompletion: "accept requests.", Fields: memberFields()}, + {RequirementID: "REQ-MODEL-002", StatementCompletion: "reject malformed requests.", Fields: memberFields()}, + }, + }, + { + GroupID: "RGRP-MODEL-DEFERRED", + Members: []Member{ + {RequirementID: "REQ-MODEL-003", StatementCompletion: "Deferred behavior remains owner-reviewed.", Fields: fullFields(ClaimDeferred, active, deferral)}, + }, + }, + { + GroupID: "RGRP-MODEL-SUPERSEDED", + Members: []Member{ + { + RequirementID: "REQ-MODEL-004", + StatementCompletion: "Historical behavior is superseded.", + Fields: fullFields(ClaimAdvisory, Lifecycle{ + State: LifecycleSuperseded, + ReplacementRequirementIDs: []string{"REQ-MODEL-001"}, + EvidenceRefs: []string{"docs/evidence/superseded.md"}, + }, nil), + }, + }, + }, + }, + Scenarios: []Scenario{ + { + ScenarioID: "SCN-MODEL-REQUEST", + RequirementIDs: []string{"REQ-MODEL-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: []Example{ + {ExampleID: "EX-MODEL-REQUEST-001", Values: map[string]ScenarioValue{"surface": "primary"}}, + {ExampleID: "EX-MODEL-REQUEST-002", Values: map[string]ScenarioValue{"surface": "secondary"}}, + }, + VocabularyRefs: []string{"TERM-MODEL-SERVICE"}, + NonClaimRefs: []string{"NCL-MODEL-003"}, + }, + }, + } +} + +func reverseDefinitions(values []NonClaimDefinition) { + reverse(len(values), func(left, right int) { values[left], values[right] = values[right], values[left] }) +} +func reverseVocabulary(values []VocabularyTerm) { + reverse(len(values), func(left, right int) { values[left], values[right] = values[right], values[left] }) +} +func reverseDerivations(values []Derivation) { + reverse(len(values), func(left, right int) { values[left], values[right] = values[right], values[left] }) +} +func reverseProfiles(values []Profile) { + reverse(len(values), func(left, right int) { values[left], values[right] = values[right], values[left] }) +} +func reverseGroups(values []Group) { + reverse(len(values), func(left, right int) { values[left], values[right] = values[right], values[left] }) +} +func reverseScenarios(values []Scenario) { + reverse(len(values), func(left, right int) { values[left], values[right] = values[right], values[left] }) +} + +func reverse(length int, swap func(int, int)) { + for left, right := 0, length-1; left < right; left, right = left+1, right-1 { + swap(left, right) + } +} diff --git a/internal/kernel/requirementsourcemodel/mutant_relation_negative_test.go b/internal/kernel/requirementsourcemodel/mutant_relation_negative_test.go new file mode 100644 index 0000000..33848f3 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/mutant_relation_negative_test.go @@ -0,0 +1,107 @@ +package requirementsourcemodel + +import "testing" + +func TestMutantRelationPredicatesRejectNearMisses(t *testing.T) { + tests := []struct { + relationID mutantRelationID + mutantID string + breaks []func(Draft, *Draft) + }{ + {relationClaimDeferralPresence, "metadata-deferral-presence", []func(Draft, *Draft){func(before Draft, after *Draft) { + after.Groups[1].Members[0].Fields.ClaimLevel = before.Groups[1].Members[0].Fields.ClaimLevel + }}}, + {relationGroupIdentityMemberReference, "group-id", []func(Draft, *Draft){func(_ Draft, after *Draft) { + after.Groups[0].Members[0], after.Groups[0].Members[1] = after.Groups[0].Members[1], after.Groups[0].Members[0] + }}}, + {relationGroupMemberReassignment, "group-membership", []func(Draft, *Draft){func(before Draft, after *Draft) { + after.Groups[1].Members[0] = before.Groups[1].Members[0] + }}}, + {relationLifecycleStateEvidence, "metadata-lifecycle-state", []func(Draft, *Draft){ + func(_ Draft, after *Draft) { + lifecycle := after.Groups[1].Members[0].Fields.Lifecycle.Value + lifecycle.EvidenceRefs = nil + after.Groups[1].Members[0].Fields.Lifecycle.Value = lifecycle + }, + func(_ Draft, after *Draft) { + lifecycle := after.Groups[1].Members[0].Fields.Lifecycle.Value + lifecycle.ReplacementRequirementIDs = []string{"REQ-MODEL-001"} + after.Groups[1].Members[0].Fields.Lifecycle.Value = lifecycle + }, + }}, + {relationNonClaimReferenceRename, "nonclaim-id", []func(Draft, *Draft){func(before Draft, after *Draft) { + after.SourceNonClaimRefs[0] = before.SourceNonClaimRefs[0] + }}}, + {relationObjectFormatCommitIdentity, "derivation-object-format", []func(Draft, *Draft){func(before Draft, after *Draft) { + after.Derivations[0].SourceRef.CommitOID = before.Derivations[0].SourceRef.CommitOID + }}}, + {relationProfileReferenceRename, "profile-id", []func(Draft, *Draft){func(before Draft, after *Draft) { + after.Groups[0].ProfileID = before.Groups[0].ProfileID + }}}, + {relationProfileRemovalOwnershipMigration, "group-profile-ref", []func(Draft, *Draft){func(_ Draft, after *Draft) { + after.Groups[0].Members[0].Fields.OwnerID = Field[string]{} + }}}, + {relationRequirementReferenceRename, "member-id", []func(Draft, *Draft){func(before Draft, after *Draft) { + after.Derivations[0].RequirementIDs = cloneStrings(before.Derivations[0].RequirementIDs) + }, func(_ Draft, after *Draft) { + after.Derivations[0].RequirementIDs = append(after.Derivations[0].RequirementIDs, "REQ-MODEL-003") + }, func(_ Draft, after *Draft) { + lifecycle := after.Groups[2].Members[0].Fields.Lifecycle.Value + lifecycle.ReplacementRequirementIDs = append(lifecycle.ReplacementRequirementIDs, "REQ-MODEL-005") + after.Groups[2].Members[0].Fields.Lifecycle.Value = lifecycle + }}}, + {relationScenarioParameterRename, "scenario-parameters", []func(Draft, *Draft){func(before Draft, after *Draft) { + after.Scenarios[0].Preconditions = cloneStrings(before.Scenarios[0].Preconditions) + }, func(_ Draft, after *Draft) { + after.Scenarios[0].Examples[0].Values["channel"] = "rewritten" + }, func(_ Draft, after *Draft) { + after.Scenarios[0].ActionSequence[0] = "Submit a ${channel} request." + }, func(_ Draft, after *Draft) { + after.Scenarios[0].ExpectedObservations[0] = "The ${channel} request is accepted." + }, func(_ Draft, after *Draft) { + after.Scenarios[0].ForbiddenObservations[0] = "The ${channel} service exposes a secret." + }}}, + {relationScenarioVocabularyExtension, "scenario-vocabulary", []func(Draft, *Draft){func(before Draft, after *Draft) { + after.Scenarios[0].VocabularyRefs = cloneStrings(before.Scenarios[0].VocabularyRefs) + }, func(_ Draft, after *Draft) { + after.Vocabulary[0].Definition = "A changed existing definition." + }}}, + {relationVocabularyReferenceRename, "vocabulary-id", []func(Draft, *Draft){func(before Draft, after *Draft) { + after.Scenarios[0].VocabularyRefs = cloneStrings(before.Scenarios[0].VocabularyRefs) + }}}, + } + registry := mutantRelationRegistry() + if len(tests) != len(registry) { + t.Fatalf("negative relation cases = %d, registry = %d", len(tests), len(registry)) + } + seen := map[mutantRelationID]struct{}{} + for _, test := range tests { + t.Run(string(test.relationID), func(t *testing.T) { + relation, exists := registry[test.relationID] + if !exists { + t.Fatalf("unknown relation %q", test.relationID) + } + if _, duplicate := seen[test.relationID]; duplicate { + t.Fatalf("duplicate negative relation case %q", test.relationID) + } + seen[test.relationID] = struct{}{} + before := validDraft() + after := cloneDraft(before) + if !applyMutant(test.mutantID, &after) || !relation.holds(before, after) { + t.Fatalf("coordinated mutant %q does not satisfy relation %q", test.mutantID, test.relationID) + } + for index, breakRelation := range test.breaks { + nearMiss := cloneDraft(after) + breakRelation(before, &nearMiss) + if relation.holds(before, nearMiss) { + t.Fatalf("relation %q accepted near miss %d", test.relationID, index) + } + } + contaminated := cloneDraft(after) + contaminated.SpecPackagePath = "docs/specs/contaminated" + if relation.holds(before, contaminated) { + t.Fatalf("relation %q accepted an unrelated draft change", test.relationID) + } + }) + } +} diff --git a/internal/kernel/requirementsourcemodel/mutant_relations_test.go b/internal/kernel/requirementsourcemodel/mutant_relations_test.go new file mode 100644 index 0000000..7f72d15 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/mutant_relations_test.go @@ -0,0 +1,318 @@ +package requirementsourcemodel + +import ( + "fmt" + "reflect" + "sort" + "strings" +) + +type mutantRelation struct { + fields []string + holds func(Draft, Draft) bool +} + +type mutantRelationID string + +const ( + relationClaimDeferralPresence mutantRelationID = "claim-deferral-presence" + relationGroupIdentityMemberReference mutantRelationID = "group-identity-member-reference" + relationGroupMemberReassignment mutantRelationID = "group-member-reassignment" + relationLifecycleStateEvidence mutantRelationID = "lifecycle-state-evidence" + relationNonClaimReferenceRename mutantRelationID = "nonclaim-reference-rename" + relationObjectFormatCommitIdentity mutantRelationID = "object-format-commit-identity" + relationProfileReferenceRename mutantRelationID = "profile-reference-rename" + relationProfileRemovalOwnershipMigration mutantRelationID = "profile-removal-ownership-migration" + relationRequirementReferenceRename mutantRelationID = "requirement-reference-rename" + relationScenarioParameterRename mutantRelationID = "scenario-parameter-rename" + relationScenarioVocabularyExtension mutantRelationID = "scenario-vocabulary-extension" + relationVocabularyReferenceRename mutantRelationID = "vocabulary-reference-rename" +) + +func mutantRelationRegistry() map[mutantRelationID]mutantRelation { + return map[mutantRelationID]mutantRelation{ + relationClaimDeferralPresence: { + fields: []string{"metadata.claimLevel", "metadata.deferral.evidenceRefs", "metadata.deferral.expiryRef", "metadata.deferral.mergePolicy", "metadata.deferral.ownerId", "metadata.deferral.presence", "metadata.deferral.reviewCondition", "metadata.deferral.riskAcceptedBy"}, + holds: func(before Draft, after Draft) bool { + left := before.Groups[1].Members[0].Fields + right := after.Groups[1].Members[0].Fields + if left.ClaimLevel.Value != ClaimDeferred || left.Deferral.Value == nil || right.ClaimLevel.Value == ClaimDeferred || !right.Deferral.Present || right.Deferral.Value != nil { + return false + } + expected := cloneDraft(before) + expected.Groups[1].Members[0].Fields.ClaimLevel = right.ClaimLevel + expected.Groups[1].Members[0].Fields.Deferral = right.Deferral + return reflect.DeepEqual(after, expected) + }, + }, + relationGroupIdentityMemberReference: { + fields: []string{"group.id", "group.memberRefs"}, + holds: func(before Draft, after Draft) bool { + expected := cloneDraft(before) + expected.Groups[0].GroupID = "RGRP-MODEL-REQUESTS-V2" + return reflect.DeepEqual(after, expected) + }, + }, + relationGroupMemberReassignment: { + fields: []string{"group.memberRefs"}, + holds: func(before Draft, after Draft) bool { + expected := cloneDraft(before) + expected.Groups[1].Members[0], expected.Groups[2].Members[0] = expected.Groups[2].Members[0], expected.Groups[1].Members[0] + return reflect.DeepEqual(after, expected) + }, + }, + + relationLifecycleStateEvidence: { + fields: []string{"metadata.lifecycle.evidenceRefs", "metadata.lifecycle.state"}, + holds: func(before Draft, after Draft) bool { + left := before.Groups[1].Members[0].Fields.Lifecycle.Value + right := after.Groups[1].Members[0].Fields.Lifecycle.Value + if left.State == right.State || len(left.EvidenceRefs) != 0 || len(right.EvidenceRefs) == 0 { + return false + } + expected := cloneDraft(before) + lifecycle := expected.Groups[1].Members[0].Fields.Lifecycle.Value + lifecycle.State = right.State + lifecycle.EvidenceRefs = cloneStrings(right.EvidenceRefs) + expected.Groups[1].Members[0].Fields.Lifecycle.Value = lifecycle + return reflect.DeepEqual(after, expected) + }, + }, + relationNonClaimReferenceRename: { + fields: []string{"nonClaim.id", "source.nonClaimRefs"}, + holds: func(before Draft, after Draft) bool { + oldID := before.NonClaimDefinitions[0].NonClaimID + newID := after.NonClaimDefinitions[0].NonClaimID + if oldID == newID || after.SourceNonClaimRefs[0] != newID { + return false + } + expected := cloneDraft(before) + expected.NonClaimDefinitions[0].NonClaimID = newID + expected.SourceNonClaimRefs[0] = newID + return reflect.DeepEqual(expected, after) + }, + }, + relationObjectFormatCommitIdentity: { + fields: []string{"derivation.sourceRef.commitOid", "derivation.sourceRef.objectFormat"}, + holds: func(before Draft, after Draft) bool { + left := before.Derivations[0].SourceRef + right := after.Derivations[0].SourceRef + if left.ObjectFormat == right.ObjectFormat || right.ObjectFormat != ObjectSHA256 || len(right.CommitOID) != 64 { + return false + } + expected := cloneDraft(before) + expected.Derivations[0].SourceRef.ObjectFormat = right.ObjectFormat + expected.Derivations[0].SourceRef.CommitOID = right.CommitOID + return reflect.DeepEqual(after, expected) + }, + }, + relationProfileReferenceRename: { + fields: []string{"group.profileRef", "profile.id"}, + holds: func(before Draft, after Draft) bool { + oldID := before.Profiles[0].ProfileID + newID := after.Profiles[0].ProfileID + if oldID == newID { + return false + } + expected := cloneDraft(before) + expected.Profiles[0].ProfileID = newID + replaceProfileReferences(expected.Groups, oldID, newID) + return reflect.DeepEqual(after, expected) + }, + }, + relationProfileRemovalOwnershipMigration: { + fields: []string{"group.profileRef", "metadata.claimLevel", "metadata.ownerId", "metadata.riskClass", "metadata.updatePolicy.requiresImpactDeclaration", "metadata.updatePolicy.requiresProofBindingReview", "metadata.updatePolicy.reviewOwnerId", "profile.id"}, + holds: func(before Draft, after Draft) bool { + if len(before.Profiles) != 1 || len(after.Profiles) != 0 || after.Groups[0].ProfileID != "" { + return false + } + expected := cloneDraft(before) + removeProfileWithoutChangingSemantics(&expected) + if !reflect.DeepEqual(after, expected) { + return false + } + left, leftErr := Normalize(before) + right, rightErr := Normalize(after) + return leftErr == nil && rightErr == nil && reflect.DeepEqual(left.Atomic(), right.Atomic()) + }, + }, + relationRequirementReferenceRename: { + fields: []string{"derivation.requirementIds", "group.memberRefs", "member.requirementId"}, + holds: func(before Draft, after Draft) bool { + oldID := before.Groups[0].Members[1].RequirementID + newID := after.Groups[0].Members[1].RequirementID + if oldID == newID { + return false + } + expected := cloneDraft(before) + replaceRequirementMemberAndDerivationReferences(&expected, oldID, newID) + return reflect.DeepEqual(after, expected) + }, + }, + relationScenarioParameterRename: { + fields: []string{"scenario.example.values", "scenario.parameters", "scenario.preconditions"}, + holds: func(before Draft, after Draft) bool { + oldParameter, newParameter, ok := singleParameterRename(before.Scenarios[0].Parameters, after.Scenarios[0].Parameters) + if !ok { + return false + } + expected := cloneDraft(before) + renameScenarioParameter(&expected.Scenarios[0], oldParameter, newParameter) + return reflect.DeepEqual(after, expected) + }, + }, + relationScenarioVocabularyExtension: { + fields: []string{"scenario.vocabularyRefs", "vocabulary.definition", "vocabulary.id", "vocabulary.kind", "vocabulary.label"}, + holds: func(before Draft, after Draft) bool { + added, ok := singleAddedVocabularyTerm(before.Vocabulary, after.Vocabulary) + if !ok { + return false + } + expected := cloneDraft(before) + expected.Vocabulary = append(expected.Vocabulary, added) + expected.Scenarios[0].VocabularyRefs = append(expected.Scenarios[0].VocabularyRefs, added.TermID) + sort.Strings(expected.Scenarios[0].VocabularyRefs) + return reflect.DeepEqual(after, expected) + }, + }, + relationVocabularyReferenceRename: { + fields: []string{"scenario.vocabularyRefs", "vocabulary.id"}, + holds: func(before Draft, after Draft) bool { + oldID := before.Vocabulary[0].TermID + newID := after.Vocabulary[0].TermID + if oldID == newID { + return false + } + expected := cloneDraft(before) + expected.Vocabulary[0].TermID = newID + for index := range expected.Scenarios { + replaceStringReferences(expected.Scenarios[index].VocabularyRefs, oldID, newID) + } + return reflect.DeepEqual(after, expected) + }, + }, + } +} + +func replaceProfileReferences(groups []Group, oldID string, newID string) { + for index := range groups { + if groups[index].ProfileID == oldID { + groups[index].ProfileID = newID + } + } +} + +func replaceRequirementMemberAndDerivationReferences(draft *Draft, oldID string, newID string) { + for groupIndex := range draft.Groups { + for memberIndex := range draft.Groups[groupIndex].Members { + member := &draft.Groups[groupIndex].Members[memberIndex] + if member.RequirementID == oldID { + member.RequirementID = newID + } + } + } + for index := range draft.Derivations { + replaceStringReferences(draft.Derivations[index].RequirementIDs, oldID, newID) + } +} + +func replaceStringReferences(values []string, oldID string, newID string) { + for index := range values { + if values[index] == oldID { + values[index] = newID + } + } +} + +func singleParameterRename(before []string, after []string) (string, string, bool) { + if len(before) != len(after) { + return "", "", false + } + beforeSet := make(map[string]struct{}, len(before)) + afterSet := make(map[string]struct{}, len(after)) + for _, value := range before { + beforeSet[value] = struct{}{} + } + for _, value := range after { + afterSet[value] = struct{}{} + } + removed := "" + added := "" + for value := range beforeSet { + if _, exists := afterSet[value]; !exists { + if removed != "" { + return "", "", false + } + removed = value + } + } + for value := range afterSet { + if _, exists := beforeSet[value]; !exists { + if added != "" { + return "", "", false + } + added = value + } + } + return removed, added, removed != "" && added != "" +} + +func renameScenarioParameter(scenario *Scenario, oldParameter string, newParameter string) { + replaceStringReferences(scenario.Parameters, oldParameter, newParameter) + sort.Strings(scenario.Parameters) + for index := range scenario.Preconditions { + scenario.Preconditions[index] = strings.ReplaceAll(scenario.Preconditions[index], "${"+oldParameter+"}", "${"+newParameter+"}") + } + for index := range scenario.Examples { + value, exists := scenario.Examples[index].Values[oldParameter] + if !exists { + continue + } + delete(scenario.Examples[index].Values, oldParameter) + scenario.Examples[index].Values[newParameter] = value + } +} + +func singleAddedVocabularyTerm(before []VocabularyTerm, after []VocabularyTerm) (VocabularyTerm, bool) { + if len(after) != len(before)+1 { + return VocabularyTerm{}, false + } + beforeByID := make(map[string]VocabularyTerm, len(before)) + for _, term := range before { + beforeByID[term.TermID] = term + } + added := VocabularyTerm{} + found := false + for _, term := range after { + baseline, exists := beforeByID[term.TermID] + if exists { + if !reflect.DeepEqual(term, baseline) { + return VocabularyTerm{}, false + } + continue + } + if found { + return VocabularyTerm{}, false + } + added = term + found = true + } + return added, found +} + +func validateMutantRelations(record mutantRecord, before Draft, after Draft) error { + registry := mutantRelationRegistry() + for _, relationID := range record.RelationIDs { + relation, exists := registry[mutantRelationID(relationID)] + if !exists { + return fmt.Errorf("unknown relation %q", relationID) + } + if !reflect.DeepEqual(relation.fields, record.MutatedFields) { + return fmt.Errorf("relation %q fields %v do not match %v", relationID, relation.fields, record.MutatedFields) + } + if !relation.holds(before, after) { + return fmt.Errorf("relation %q predicate is false", relationID) + } + } + return nil +} diff --git a/internal/kernel/requirementsourcemodel/mutants_test.go b/internal/kernel/requirementsourcemodel/mutants_test.go new file mode 100644 index 0000000..a0ed901 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/mutants_test.go @@ -0,0 +1,154 @@ +package requirementsourcemodel + +func mutantImplementations() map[string]func(*Draft) { + return map[string]func(*Draft){ + "source-id": func(draft *Draft) { draft.SourceID = "proofkit.model.changed" }, + "source-package-path": func(draft *Draft) { draft.SpecPackagePath = "docs/specs/proofkit-model-v2" }, + "source-nonclaim-refs": func(draft *Draft) { draft.SourceNonClaimRefs = []string{"NCL-MODEL-001", "NCL-MODEL-002"} }, + "nonclaim-id": func(draft *Draft) { + draft.NonClaimDefinitions[0].NonClaimID = "NCL-MODEL-005" + draft.SourceNonClaimRefs[0] = "NCL-MODEL-005" + }, + "nonclaim-statement": func(draft *Draft) { draft.NonClaimDefinitions[0].Statement += " It is declaration-only." }, + "vocabulary-id": func(draft *Draft) { + draft.Vocabulary[0].TermID = "TERM-MODEL-SERVICE-V2" + draft.Scenarios[0].VocabularyRefs[0] = "TERM-MODEL-SERVICE-V2" + }, + "vocabulary-kind": func(draft *Draft) { draft.Vocabulary[0].Kind = TermState }, + "vocabulary-label": func(draft *Draft) { draft.Vocabulary[0].Label = "bounded service" }, + "vocabulary-definition": func(draft *Draft) { draft.Vocabulary[0].Definition += " It is repository-owned." }, + "derivation-id": func(draft *Draft) { draft.Derivations[0].DerivationID = "DRV-MODEL-002" }, + "derivation-source-kind": func(draft *Draft) { draft.Derivations[0].SourceKind = SourceDesign }, + "derivation-object-format": func(draft *Draft) { + draft.Derivations[0].SourceRef.ObjectFormat = ObjectSHA256 + draft.Derivations[0].SourceRef.CommitOID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + }, + "derivation-commit-oid": func(draft *Draft) { + draft.Derivations[0].SourceRef.CommitOID = "1123456789abcdef0123456789abcdef01234567" + }, + "derivation-path": func(draft *Draft) { draft.Derivations[0].SourceRef.Path = "docs/decisions/model-v2.md" }, + "derivation-sha256": func(draft *Draft) { + draft.Derivations[0].SourceRef.SHA256 = "1123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + }, + "derivation-selector-start": func(draft *Draft) { draft.Derivations[0].Selector.Start = 1 }, + "derivation-selector-end": func(draft *Draft) { draft.Derivations[0].Selector.End = 65 }, + "derivation-requirements": func(draft *Draft) { draft.Derivations[0].RequirementIDs = []string{"REQ-MODEL-001"} }, + "derivation-nonclaims": func(draft *Draft) { draft.Derivations[0].NonClaimRefs = []string{"NCL-MODEL-002", "NCL-MODEL-004"} }, + "profile-id": func(draft *Draft) { + draft.Profiles[0].ProfileID = "RPROF-MODEL-BLOCKING-V2" + draft.Groups[0].ProfileID = "RPROF-MODEL-BLOCKING-V2" + }, + "metadata-owner": func(draft *Draft) { draft.Profiles[0].Fields.OwnerID.Value = "proofkit.model.changed" }, + "metadata-claim-level": func(draft *Draft) { draft.Profiles[0].Fields.ClaimLevel.Value = ClaimAdvisory }, + "metadata-risk-class": func(draft *Draft) { draft.Profiles[0].Fields.RiskClass.Value = RiskCritical }, + "metadata-nonclaims": func(draft *Draft) { + draft.Groups[0].Members[0].Fields.NonClaimRefs.Value = []string{"NCL-MODEL-001", "NCL-MODEL-002"} + }, + "metadata-lifecycle-state": func(draft *Draft) { + lifecycle := draft.Groups[1].Members[0].Fields.Lifecycle.Value + lifecycle.State = LifecycleDeprecated + lifecycle.EvidenceRefs = []string{"docs/evidence/deprecated.md"} + draft.Groups[1].Members[0].Fields.Lifecycle.Value = lifecycle + }, + "metadata-lifecycle-replacements": func(draft *Draft) { + lifecycle := draft.Groups[2].Members[0].Fields.Lifecycle.Value + lifecycle.ReplacementRequirementIDs = []string{"REQ-MODEL-001", "REQ-MODEL-002"} + draft.Groups[2].Members[0].Fields.Lifecycle.Value = lifecycle + }, + "metadata-lifecycle-evidence": func(draft *Draft) { + lifecycle := draft.Groups[1].Members[0].Fields.Lifecycle.Value + lifecycle.EvidenceRefs = []string{"docs/evidence/active.md"} + draft.Groups[1].Members[0].Fields.Lifecycle.Value = lifecycle + }, + "metadata-deferral-presence": func(draft *Draft) { + draft.Groups[1].Members[0].Fields.ClaimLevel.Value = ClaimAdvisory + draft.Groups[1].Members[0].Fields.Deferral.Value = nil + }, + "metadata-deferral-owner": func(draft *Draft) { + draft.Groups[1].Members[0].Fields.Deferral.Value.OwnerID = "proofkit.model.changed" + }, + "metadata-deferral-risk-owner": func(draft *Draft) { + draft.Groups[1].Members[0].Fields.Deferral.Value.RiskAcceptedBy = "proofkit.owner.changed" + }, + "metadata-deferral-review": func(draft *Draft) { + draft.Groups[1].Members[0].Fields.Deferral.Value.ReviewCondition = "Review after another experiment." + }, + "metadata-deferral-expiry": func(draft *Draft) { + draft.Groups[1].Members[0].Fields.Deferral.Value.ExpiryRef = "proofkit.model.expiry.changed" + }, + "metadata-deferral-policy": func(draft *Draft) { + draft.Groups[1].Members[0].Fields.Deferral.Value.MergePolicy = "proofkit.model.merge.changed" + }, + "metadata-deferral-evidence": func(draft *Draft) { + draft.Groups[1].Members[0].Fields.Deferral.Value.EvidenceRefs = []string{"docs/evidence/deferral-v2.md"} + }, + "metadata-update-owner": func(draft *Draft) { + draft.Groups[1].Members[0].Fields.UpdatePolicy.Value.ReviewOwnerID = "proofkit.model.changed" + }, + "metadata-update-impact": func(draft *Draft) { + draft.Groups[1].Members[0].Fields.UpdatePolicy.Value.RequiresImpactDeclaration = false + }, + "metadata-update-binding": func(draft *Draft) { + draft.Groups[1].Members[0].Fields.UpdatePolicy.Value.RequiresProofBindingReview = false + }, + "group-id": func(draft *Draft) { draft.Groups[0].GroupID = "RGRP-MODEL-REQUESTS-V2" }, + "group-membership": func(draft *Draft) { + draft.Groups[1].Members[0], draft.Groups[2].Members[0] = draft.Groups[2].Members[0], draft.Groups[1].Members[0] + }, + "group-profile-ref": removeProfileWithoutChangingSemantics, + "group-stem": func(draft *Draft) { draft.Groups[0].StatementStem = "The service shall" }, + "group-premises": func(draft *Draft) { + draft.Groups[0].SharedPremises = []string{"The service is available.", "The transport is available."} + }, + "member-id": func(draft *Draft) { + draft.Groups[0].Members[1].RequirementID = "REQ-MODEL-005" + draft.Derivations[0].RequirementIDs = []string{"REQ-MODEL-001", "REQ-MODEL-005"} + }, + "member-completion": func(draft *Draft) { draft.Groups[0].Members[0].StatementCompletion = "accept valid requests." }, + "scenario-id": func(draft *Draft) { draft.Scenarios[0].ScenarioID = "SCN-MODEL-REQUEST-V2" }, + "scenario-requirements": func(draft *Draft) { draft.Scenarios[0].RequirementIDs = []string{"REQ-MODEL-001", "REQ-MODEL-002"} }, + "scenario-parameters": func(draft *Draft) { + draft.Scenarios[0].Parameters = []string{"channel"} + draft.Scenarios[0].Preconditions[0] = "The ${channel} surface is available." + for index := range draft.Scenarios[0].Examples { + value := draft.Scenarios[0].Examples[index].Values["surface"] + draft.Scenarios[0].Examples[index].Values = map[string]ScenarioValue{"channel": value} + } + }, + "scenario-preconditions": func(draft *Draft) { draft.Scenarios[0].Preconditions[0] = "The ${surface} surface is ready." }, + "scenario-actions": func(draft *Draft) { + draft.Scenarios[0].ActionSequence[0], draft.Scenarios[0].ActionSequence[1] = draft.Scenarios[0].ActionSequence[1], draft.Scenarios[0].ActionSequence[0] + }, + "scenario-expected": func(draft *Draft) { draft.Scenarios[0].ExpectedObservations[0] = "The valid request is accepted." }, + "scenario-forbidden": func(draft *Draft) { draft.Scenarios[0].ForbiddenObservations[0] = "The service exposes credentials." }, + "scenario-example-id": func(draft *Draft) { draft.Scenarios[0].Examples[0].ExampleID = "EX-MODEL-REQUEST-003" }, + "scenario-example-values": func(draft *Draft) { draft.Scenarios[0].Examples[0].Values["surface"] = "tertiary" }, + "scenario-vocabulary": func(draft *Draft) { + draft.Vocabulary = append(draft.Vocabulary, VocabularyTerm{TermID: "TERM-MODEL-REQUEST", Kind: TermAction, Label: "request", Definition: "A bounded request value."}) + draft.Scenarios[0].VocabularyRefs = []string{"TERM-MODEL-REQUEST", "TERM-MODEL-SERVICE"} + }, + "scenario-nonclaims": func(draft *Draft) { draft.Scenarios[0].NonClaimRefs = []string{"NCL-MODEL-002", "NCL-MODEL-003"} }, + } +} + +func applyMutant(id string, draft *Draft) bool { + mutation, exists := mutantImplementations()[id] + if !exists { + return false + } + mutation(draft) + return true +} + +func removeProfileWithoutChangingSemantics(draft *Draft) { + profile := draft.Profiles[0].Fields + for index := range draft.Groups[0].Members { + fields := &draft.Groups[0].Members[index].Fields + fields.OwnerID = profile.OwnerID + fields.ClaimLevel = profile.ClaimLevel + fields.RiskClass = profile.RiskClass + fields.UpdatePolicy = profile.UpdatePolicy + } + draft.Groups[0].ProfileID = "" + draft.Profiles = nil +} diff --git a/internal/kernel/requirementsourcemodel/normalize.go b/internal/kernel/requirementsourcemodel/normalize.go new file mode 100644 index 0000000..1f34016 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/normalize.go @@ -0,0 +1,251 @@ +package requirementsourcemodel + +import ( + "sort" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +func Normalize(draft Draft) (Model, error) { + return NormalizeWithLimits(draft, DefaultLimits()) +} + +func NormalizeWithLimits(draft Draft, limits Limits) (Model, error) { + if err := validateLimits(limits); err != nil { + return Model{}, err + } + if err := preflight(draft, limits); err != nil { + return Model{}, err + } + snapshot := cloneDraft(draft) + if err := preflight(snapshot, limits); err != nil { + return Model{}, err + } + + sourceID, err := canonicalExternalID(snapshot.SourceID, "sourceId") + if err != nil { + return Model{}, err + } + specPackagePath, err := canonicalPath(snapshot.SpecPackagePath, "specPackagePath") + if err != nil { + return Model{}, err + } + sourceNonClaimRefs, err := normalizeIDs(snapshot.SourceNonClaimRefs, "NCL-", "sourceNonClaimRefs", true) + if err != nil { + return Model{}, err + } + + definitions, definitionIDs, err := normalizeDefinitions(snapshot.NonClaimDefinitions) + if err != nil { + return Model{}, err + } + vocabulary, vocabularyIDs, err := normalizeVocabulary(snapshot.Vocabulary) + if err != nil { + return Model{}, err + } + profiles, profilesByID, err := normalizeProfiles(snapshot.Profiles) + if err != nil { + return Model{}, err + } + groups, requirements, origins, profileUses, err := normalizeGroups(snapshot.Groups, profilesByID) + if err != nil { + return Model{}, err + } + if err := validateProfileUses(profiles, profileUses); err != nil { + return Model{}, err + } + requirementsByID := make(map[string]AtomicRequirement, len(requirements)) + for _, requirement := range requirements { + requirementsByID[requirement.RequirementID] = requirement + } + if err := validateRequirementLifecycles(requirements, requirementsByID); err != nil { + return Model{}, err + } + + scenarios, err := normalizeScenarios(snapshot.Scenarios, requirementsByID, vocabularyIDs) + if err != nil { + return Model{}, err + } + derivations, err := normalizeDerivations(snapshot.Derivations, requirementsByID) + if err != nil { + return Model{}, err + } + edges := buildReferenceEdges(sourceID, sourceNonClaimRefs, groups, requirements, scenarios, derivations) + if err := validateReferenceClosure(definitionIDs, vocabularyIDs, edges); err != nil { + return Model{}, err + } + + atomic := AtomicProjection{ + SourceID: sourceID, + SpecPackagePath: specPackagePath, + SourceNonClaimRefs: sourceNonClaimRefs, + NonClaimDefinitions: definitions, + Vocabulary: vocabulary, + Requirements: requirements, + Scenarios: scenarios, + } + layout := LayoutProjection{SourceID: sourceID, Profiles: profiles, Groups: groups, Origins: origins} + references := ReferenceProjection{SourceID: sourceID, Derivations: derivations, Edges: edges} + return Model{ + atomic: cloneAtomicProjection(atomic), + layout: cloneLayoutProjection(layout), + references: cloneReferenceProjection(references), + }, nil +} + +func normalizeDefinitions(values []NonClaimDefinition) ([]NonClaimDefinition, map[string]struct{}, error) { + result := make([]NonClaimDefinition, len(values)) + ids := make(map[string]struct{}, len(values)) + statements := make(map[string]struct{}, len(values)) + for index, value := range values { + path := indexed("nonClaimDefinitions", index, "") + id, err := canonicalID(value.NonClaimID, "NCL-", path+"nonClaimId") + if err != nil { + return nil, nil, err + } + if _, exists := ids[id]; exists { + return nil, nil, invalid("duplicate_id", "nonClaimDefinitions") + } + statement, err := canonicalText(value.Statement, path+"statement", false, true) + if err != nil { + return nil, nil, err + } + if _, exists := statements[statement]; exists { + return nil, nil, invalid("duplicate_definition", "nonClaimDefinitions") + } + ids[id] = struct{}{} + statements[statement] = struct{}{} + result[index] = NonClaimDefinition{NonClaimID: id, Statement: statement} + } + sort.Slice(result, func(left int, right int) bool { return result[left].NonClaimID < result[right].NonClaimID }) + return result, ids, nil +} + +func normalizeVocabulary(values []VocabularyTerm) ([]VocabularyTerm, map[string]struct{}, error) { + result := make([]VocabularyTerm, len(values)) + ids := make(map[string]struct{}, len(values)) + for index, value := range values { + path := indexed("vocabulary", index, "") + id, err := canonicalID(value.TermID, "TERM-", path+"termId") + if err != nil { + return nil, nil, err + } + if _, exists := ids[id]; exists { + return nil, nil, invalid("duplicate_id", "vocabulary") + } + if err := validTermKind(value.Kind, path+"kind"); err != nil { + return nil, nil, err + } + label, err := canonicalText(value.Label, path+"label", false, false) + if err != nil { + return nil, nil, err + } + definition, err := canonicalText(value.Definition, path+"definition", false, true) + if err != nil { + return nil, nil, err + } + ids[id] = struct{}{} + result[index] = VocabularyTerm{TermID: id, Kind: value.Kind, Label: label, Definition: definition} + } + sort.Slice(result, func(left int, right int) bool { return result[left].TermID < result[right].TermID }) + return result, ids, nil +} + +func normalizeProfiles(values []Profile) ([]Profile, map[string]Profile, error) { + result := make([]Profile, len(values)) + byID := make(map[string]Profile, len(values)) + for index, value := range values { + path := indexed("profiles", index, "") + id, err := canonicalID(value.ProfileID, "RPROF-", path+"profileId") + if err != nil { + return nil, nil, err + } + if _, exists := byID[id]; exists { + return nil, nil, invalid("duplicate_id", "profiles") + } + fields, err := normalizeMetadataFields(value.Fields, path+"fields") + if err != nil { + return nil, nil, err + } + if metadataFieldCount(fields) == 0 { + return nil, nil, invalid("empty_profile", path+"fields") + } + profile := Profile{ProfileID: id, Fields: fields} + result[index] = profile + byID[id] = profile + } + sort.Slice(result, func(left int, right int) bool { return result[left].ProfileID < result[right].ProfileID }) + return result, byID, nil +} + +func normalizeDerivations(values []Derivation, requirements map[string]AtomicRequirement) ([]Derivation, error) { + result := make([]Derivation, len(values)) + ids := make(map[string]struct{}, len(values)) + for index, value := range values { + path := indexed("derivations", index, "") + id, err := canonicalID(value.DerivationID, "DRV-", path+"derivationId") + if err != nil { + return nil, err + } + if _, exists := ids[id]; exists { + return nil, invalid("duplicate_id", "derivations") + } + if err := validSourceKind(value.SourceKind, path+"sourceKind"); err != nil { + return nil, err + } + sourceRef, err := normalizeGitBlobRef(value.SourceRef, path+"sourceRef") + if err != nil { + return nil, err + } + if value.Selector.Start < 0 || value.Selector.End <= value.Selector.Start { + return nil, invalid("invalid_byte_range", path+"selector") + } + requirementIDs, err := normalizeIDs(value.RequirementIDs, "REQ-", path+"requirementIds", false) + if err != nil { + return nil, err + } + for _, requirementID := range requirementIDs { + if _, exists := requirements[requirementID]; !exists { + return nil, invalid("dangling_requirement_ref", path+"requirementIds") + } + } + nonClaimRefs, err := normalizeIDs(value.NonClaimRefs, "NCL-", path+"nonClaimRefs", true) + if err != nil { + return nil, err + } + ids[id] = struct{}{} + result[index] = Derivation{ + DerivationID: id, + SourceKind: value.SourceKind, + SourceRef: sourceRef, + Selector: value.Selector, + RequirementIDs: requirementIDs, + NonClaimRefs: nonClaimRefs, + } + } + sort.Slice(result, func(left int, right int) bool { return result[left].DerivationID < result[right].DerivationID }) + return result, nil +} + +func normalizeGitBlobRef(value GitBlobRef, path string) (GitBlobRef, error) { + if err := validObjectFormat(value.ObjectFormat, path+".objectFormat"); err != nil { + return GitBlobRef{}, err + } + expectedLength := 40 + if value.ObjectFormat == ObjectSHA256 { + expectedLength = 64 + } + if len(value.CommitOID) != expectedLength || strings.ToLower(value.CommitOID) != value.CommitOID || strings.Trim(value.CommitOID, "0123456789abcdef") != "" { + return GitBlobRef{}, invalid("invalid_commit_oid", path+".commitOid") + } + refPath, err := canonicalPath(value.Path, path+".path") + if err != nil { + return GitBlobRef{}, err + } + digest, err := admit.LowercaseSHA256(value.SHA256, path+".sha256") + if err != nil { + return GitBlobRef{}, invalid("invalid_sha256", path+".sha256") + } + return GitBlobRef{ObjectFormat: value.ObjectFormat, CommitOID: value.CommitOID, Path: refPath, SHA256: digest}, nil +} diff --git a/internal/kernel/requirementsourcemodel/package_boundary_test.go b/internal/kernel/requirementsourcemodel/package_boundary_test.go new file mode 100644 index 0000000..7da7929 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/package_boundary_test.go @@ -0,0 +1,67 @@ +package requirementsourcemodel + +import ( + "go/ast" + "reflect" + "sort" + "testing" + + "golang.org/x/tools/go/packages" +) + +func TestRepresentationNeutralPackageBoundaryIsExact(t *testing.T) { + loaded, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedTypes | packages.NeedImports, + }, ".") + if err != nil { + t.Fatal(err) + } + if packages.PrintErrors(loaded) != 0 || len(loaded) != 1 { + t.Fatalf("loaded production packages = %d", len(loaded)) + } + + actualExports := []string{} + for _, name := range loaded[0].Types.Scope().Names() { + if ast.IsExported(name) { + actualExports = append(actualExports, name) + } + } + expectedExports := []string{ + "AtomicProjection", "AtomicRequirement", "ByteRange", + "ClaimAdvisory", "ClaimBlocking", "ClaimDeferred", "ClaimLevel", + "DefaultLimits", "Deferral", "Derivation", "Draft", + "EntityDerivation", "EntityGroup", "EntityKind", "EntityNonClaim", "EntityProfile", "EntityRequirement", "EntityScenario", "EntitySource", "EntityTerm", + "ErrorCode", "Example", "Field", "FieldOwner", "GitBlobRef", "Group", + "LayoutProjection", "Lifecycle", "LifecycleActive", "LifecycleDeprecated", "LifecycleRemoved", "LifecycleState", "LifecycleSuperseded", "Limits", + "Member", "MetadataFieldID", "MetadataFields", "MetadataOwnerKind", "MetadataOwnerMember", "MetadataOwnerProfile", "Model", + "NonClaimDefinition", "Normalize", "NormalizeWithLimits", + "ObjectFormat", "ObjectSHA1", "ObjectSHA256", "Origin", "Own", "Profile", + "ReferenceDerivationNonClaim", "ReferenceDerivationRequirement", "ReferenceEdge", "ReferenceEndpoint", "ReferenceGroupMember", "ReferenceGroupProfile", "ReferenceKind", "ReferenceLifecycleReplacement", "ReferenceProjection", "ReferenceRequirementNonClaim", "ReferenceScenarioNonClaim", "ReferenceScenarioRequirement", "ReferenceScenarioVocabulary", "ReferenceSourceNonClaim", + "RiskClass", "RiskCritical", "RiskHigh", "RiskLow", "RiskMedium", + "Scenario", "ScenarioValue", "SourceClarification", "SourceCodeSnapshot", "SourceDesign", "SourceKind", "SourceOwnerDecision", "SourcePlan", + "TermAction", "TermKind", "TermObservable", "TermState", "TermSubject", "TermValue", + "UpdatePolicy", "ValidationError", "VocabularyTerm", + } + sort.Strings(actualExports) + sort.Strings(expectedExports) + if !reflect.DeepEqual(actualExports, expectedExports) { + t.Fatalf("production exports = %v, exact model boundary = %v", actualExports, expectedExports) + } + + actualImports := make([]string, 0, len(loaded[0].Imports)) + for path := range loaded[0].Imports { + actualImports = append(actualImports, path) + } + sort.Strings(actualImports) + expectedImports := []string{ + "fmt", + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit", + "reflect", + "regexp", + "sort", + "strings", + } + if !reflect.DeepEqual(actualImports, expectedImports) { + t.Fatalf("production direct imports = %v, representation-neutral allowlist = %v", actualImports, expectedImports) + } +} diff --git a/internal/kernel/requirementsourcemodel/projection_budget.go b/internal/kernel/requirementsourcemodel/projection_budget.go new file mode 100644 index 0000000..6d6d696 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/projection_budget.go @@ -0,0 +1,286 @@ +package requirementsourcemodel + +const materializationCopies = 2 + +type projectionCost struct { + Items uint64 + TextBytes uint64 +} + +type projectionBudget struct { + cost projectionCost + itemOverflow bool + textOverflow bool +} + +func (budget *projectionBudget) records(count int) { + if count < 0 || !addMultiplied(&budget.cost.Items, uint64(count), materializationCopies) { + budget.itemOverflow = true + } +} + +func (budget *projectionBudget) text(values ...string) { + for _, value := range values { + budget.records(1) + budget.addTextBytes(value) + } +} + +func (budget *projectionBudget) composedText(values ...string) { + budget.records(1) + for _, value := range values { + budget.addTextBytes(value) + } +} + +func (budget *projectionBudget) addTextBytes(value string) { + if !addMultiplied(&budget.cost.TextBytes, uint64(len(value)), materializationCopies) { + budget.textOverflow = true + } +} + +func (budget *projectionBudget) texts(values []string) { + for _, value := range values { + budget.text(value) + } +} + +func addMultiplied(total *uint64, value uint64, multiplier uint64) bool { + maximum := ^uint64(0) + if multiplier != 0 && value > maximum/multiplier { + return false + } + amount := value * multiplier + if amount > maximum-*total { + return false + } + *total += amount + return true +} + +func preflightExpandedProjectionBudget(draft Draft, limits Limits) error { + cost, itemOverflow, textOverflow := estimateExpandedProjectionCost(draft) + if itemOverflow || cost.Items > uint64(limits.MaxExpandedItems) { + return invalid("expanded_item_budget_exceeded", "draft") + } + if textOverflow || cost.TextBytes > uint64(limits.MaxExpandedTextBytes) { + return invalid("expanded_text_budget_exceeded", "draft") + } + return nil +} + +func estimateExpandedProjectionCost(draft Draft) (projectionCost, bool, bool) { + budget := &projectionBudget{} + profiles := make(map[string]MetadataFields, len(draft.Profiles)) + for _, profile := range draft.Profiles { + profiles[profile.ProfileID] = profile.Fields + } + + budget.records(3) + budget.text(draft.SourceID, draft.SpecPackagePath) + budget.texts(draft.SourceNonClaimRefs) + for _, definition := range draft.NonClaimDefinitions { + budget.records(1) + budget.text(definition.NonClaimID, definition.Statement) + } + for _, term := range draft.Vocabulary { + budget.records(1) + budget.text(term.TermID, string(term.Kind), term.Label, term.Definition) + } + for _, group := range draft.Groups { + profile := profiles[group.ProfileID] + for _, member := range group.Members { + budget.records(1) + budget.text(member.RequirementID) + separator := "" + if group.StatementStem != "" { + separator = " " + } + budget.composedText(group.StatementStem, separator, member.StatementCompletion) + budget.texts(group.SharedPremises) + budget.atomicMetadata(profile, member.Fields) + } + } + for _, scenario := range draft.Scenarios { + budget.scenario(scenario) + } + + budget.text(draft.SourceID) + for _, profile := range draft.Profiles { + budget.records(1) + budget.text(profile.ProfileID) + budget.layoutMetadata(profile.Fields) + } + for _, group := range draft.Groups { + budget.records(1) + budget.text(group.GroupID, group.ProfileID, group.StatementStem) + budget.texts(group.SharedPremises) + for _, member := range group.Members { + budget.records(1) + budget.text(member.RequirementID, member.StatementCompletion) + budget.layoutMetadata(member.Fields) + } + } + for _, group := range draft.Groups { + profile := profiles[group.ProfileID] + profilePresence := metadataPresence(profile) + for _, member := range group.Members { + budget.records(1) + budget.text(member.RequirementID, group.GroupID, group.ProfileID) + for _, fieldID := range metadataFieldIDs { + budget.records(1) + if profilePresence[fieldID] { + budget.text(string(fieldID), string(MetadataOwnerProfile), group.ProfileID) + } else { + budget.text(string(fieldID), string(MetadataOwnerMember), member.RequirementID) + } + } + } + } + + budget.text(draft.SourceID) + for _, derivation := range draft.Derivations { + budget.derivation(derivation) + } + for _, nonClaimID := range draft.SourceNonClaimRefs { + budget.edge(ReferenceSourceNonClaim, EntitySource, draft.SourceID, EntityNonClaim, nonClaimID) + } + for _, group := range draft.Groups { + if group.ProfileID != "" { + budget.edge(ReferenceGroupProfile, EntityGroup, group.GroupID, EntityProfile, group.ProfileID) + } + profile := profiles[group.ProfileID] + for _, member := range group.Members { + budget.edge(ReferenceGroupMember, EntityGroup, group.GroupID, EntityRequirement, member.RequirementID) + budget.metadataEdges(member.RequirementID, profile) + budget.metadataEdges(member.RequirementID, member.Fields) + } + } + for _, scenario := range draft.Scenarios { + for _, requirementID := range scenario.RequirementIDs { + budget.edge(ReferenceScenarioRequirement, EntityScenario, scenario.ScenarioID, EntityRequirement, requirementID) + } + for _, termID := range scenario.VocabularyRefs { + budget.edge(ReferenceScenarioVocabulary, EntityScenario, scenario.ScenarioID, EntityTerm, termID) + } + for _, nonClaimID := range scenario.NonClaimRefs { + budget.edge(ReferenceScenarioNonClaim, EntityScenario, scenario.ScenarioID, EntityNonClaim, nonClaimID) + } + } + for _, derivation := range draft.Derivations { + for _, requirementID := range derivation.RequirementIDs { + budget.edge(ReferenceDerivationRequirement, EntityDerivation, derivation.DerivationID, EntityRequirement, requirementID) + } + for _, nonClaimID := range derivation.NonClaimRefs { + budget.edge(ReferenceDerivationNonClaim, EntityDerivation, derivation.DerivationID, EntityNonClaim, nonClaimID) + } + } + + return budget.cost, budget.itemOverflow, budget.textOverflow +} + +func (budget *projectionBudget) layoutMetadata(fields MetadataFields) { + budget.records(1 + len(metadataFieldIDs)) + if fields.OwnerID.Present { + budget.text(fields.OwnerID.Value) + } + if fields.ClaimLevel.Present { + budget.text(string(fields.ClaimLevel.Value)) + } + if fields.RiskClass.Present { + budget.text(string(fields.RiskClass.Value)) + } + if fields.NonClaimRefs.Present { + budget.texts(fields.NonClaimRefs.Value) + } + if fields.Lifecycle.Present { + budget.records(1) + budget.text(string(fields.Lifecycle.Value.State)) + budget.texts(fields.Lifecycle.Value.ReplacementRequirementIDs) + budget.texts(fields.Lifecycle.Value.EvidenceRefs) + } + if fields.Deferral.Present { + if fields.Deferral.Value != nil { + budget.records(1) + value := fields.Deferral.Value + budget.text(value.OwnerID, value.RiskAcceptedBy, value.ReviewCondition, value.ExpiryRef, value.MergePolicy) + budget.texts(value.EvidenceRefs) + } + } + if fields.UpdatePolicy.Present { + budget.records(1) + budget.text(fields.UpdatePolicy.Value.ReviewOwnerID) + } +} + +func (budget *projectionBudget) atomicMetadata(profile MetadataFields, member MetadataFields) { + budget.records(2) // Lifecycle and UpdatePolicy are value fields. + budget.text( + selectedField(profile.OwnerID, member.OwnerID), + string(selectedField(profile.ClaimLevel, member.ClaimLevel)), + string(selectedField(profile.RiskClass, member.RiskClass)), + ) + budget.texts(selectedField(profile.NonClaimRefs, member.NonClaimRefs)) + lifecycle := selectedField(profile.Lifecycle, member.Lifecycle) + budget.text(string(lifecycle.State)) + budget.texts(lifecycle.ReplacementRequirementIDs) + budget.texts(lifecycle.EvidenceRefs) + if deferral := selectedField(profile.Deferral, member.Deferral); deferral != nil { + budget.records(1) + budget.text(deferral.OwnerID, deferral.RiskAcceptedBy, deferral.ReviewCondition, deferral.ExpiryRef, deferral.MergePolicy) + budget.texts(deferral.EvidenceRefs) + } + budget.text(selectedField(profile.UpdatePolicy, member.UpdatePolicy).ReviewOwnerID) +} + +func selectedField[T any](profile Field[T], member Field[T]) T { + if profile.Present { + return profile.Value + } + return member.Value +} + +func (budget *projectionBudget) scenario(value Scenario) { + budget.records(1) + budget.text(value.ScenarioID) + budget.texts(value.RequirementIDs) + budget.texts(value.Parameters) + budget.texts(value.Preconditions) + budget.texts(value.ActionSequence) + budget.texts(value.ExpectedObservations) + budget.texts(value.ForbiddenObservations) + budget.texts(value.VocabularyRefs) + budget.texts(value.NonClaimRefs) + for _, example := range value.Examples { + budget.records(2) // Example and its Values map. + budget.text(example.ExampleID) + for key, scalar := range example.Values { + budget.text(key, string(scalar)) + } + } +} + +func (budget *projectionBudget) derivation(value Derivation) { + budget.records(3) // Derivation, GitBlobRef, and ByteRange. + budget.text(value.DerivationID, string(value.SourceKind), string(value.SourceRef.ObjectFormat), value.SourceRef.CommitOID, value.SourceRef.Path, value.SourceRef.SHA256) + budget.texts(value.RequirementIDs) + budget.texts(value.NonClaimRefs) +} + +func (budget *projectionBudget) metadataEdges(requirementID string, fields MetadataFields) { + if fields.NonClaimRefs.Present { + for _, nonClaimID := range fields.NonClaimRefs.Value { + budget.edge(ReferenceRequirementNonClaim, EntityRequirement, requirementID, EntityNonClaim, nonClaimID) + } + } + if fields.Lifecycle.Present { + for _, replacementID := range fields.Lifecycle.Value.ReplacementRequirementIDs { + budget.edge(ReferenceLifecycleReplacement, EntityRequirement, requirementID, EntityRequirement, replacementID) + } + } +} + +func (budget *projectionBudget) edge(kind ReferenceKind, fromKind EntityKind, fromID string, toKind EntityKind, toID string) { + budget.records(3) // ReferenceEdge and its two typed endpoints. + budget.text(string(kind), string(fromKind), fromID, string(toKind), toID) +} diff --git a/internal/kernel/requirementsourcemodel/projection_budget_test.go b/internal/kernel/requirementsourcemodel/projection_budget_test.go new file mode 100644 index 0000000..d3782c4 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/projection_budget_test.go @@ -0,0 +1,100 @@ +package requirementsourcemodel + +import ( + "reflect" + "strings" + "testing" +) + +func TestExpandedProjectionBudgetMatchesIndependentMaterialization(t *testing.T) { + draft := validDraft() + model, err := Normalize(draft) + if err != nil { + t.Fatal(err) + } + + estimated, itemOverflow, textOverflow := estimateExpandedProjectionCost(draft) + if itemOverflow || textOverflow { + t.Fatalf("valid fixture overflowed estimate: item=%t text=%t", itemOverflow, textOverflow) + } + observed := observeStructuredCost(model.Atomic(), model.Layout(), model.References()) + const observedMaterializations = 2 + if materializationCopies != observedMaterializations { + t.Fatalf("materialization copies = %d, want %d", materializationCopies, observedMaterializations) + } + observed.Items *= observedMaterializations + observed.TextBytes *= observedMaterializations + if estimated.TextBytes != observed.TextBytes { + t.Fatalf("estimated text bytes = %d, observed = %d", estimated.TextBytes, observed.TextBytes) + } + if estimated.Items != observed.Items { + t.Fatalf("estimated items = %d, observed = %d", estimated.Items, observed.Items) + } + + limits := DefaultLimits() + limits.MaxExpandedItems = int(estimated.Items) + limits.MaxExpandedTextBytes = int(estimated.TextBytes) + if _, err := NormalizeWithLimits(draft, limits); err != nil { + t.Fatalf("exact expanded budgets rejected: %v", err) + } + + itemLimits := DefaultLimits() + itemLimits.MaxExpandedItems = int(estimated.Items - 1) + if _, err := NormalizeWithLimits(draft, itemLimits); ErrorCode(err) != "expanded_item_budget_exceeded" { + t.Fatalf("item limit-1 ErrorCode() = %q, error = %v", ErrorCode(err), err) + } + textLimits := DefaultLimits() + textLimits.MaxExpandedTextBytes = int(estimated.TextBytes - 1) + if _, err := NormalizeWithLimits(draft, textLimits); ErrorCode(err) != "expanded_text_budget_exceeded" { + t.Fatalf("text limit-1 ErrorCode() = %q, error = %v", ErrorCode(err), err) + } +} + +func observeStructuredCost(values ...any) projectionCost { + cost := projectionCost{} + for _, value := range values { + observeMaterializedValue(reflect.ValueOf(value), &cost) + } + return cost +} + +func observeMaterializedValue(value reflect.Value, cost *projectionCost) { + if !value.IsValid() { + return + } + switch value.Kind() { + case reflect.Interface, reflect.Pointer: + if !value.IsNil() { + observeMaterializedValue(value.Elem(), cost) + } + case reflect.Struct: + cost.Items++ + if isPresenceField(value.Type()) { + if value.FieldByName("Present").Bool() { + observeMaterializedValue(value.FieldByName("Value"), cost) + } + return + } + for index := 0; index < value.NumField(); index++ { + observeMaterializedValue(value.Field(index), cost) + } + case reflect.Slice, reflect.Array: + for index := 0; index < value.Len(); index++ { + observeMaterializedValue(value.Index(index), cost) + } + case reflect.Map: + cost.Items++ + iterator := value.MapRange() + for iterator.Next() { + observeMaterializedValue(iterator.Key(), cost) + observeMaterializedValue(iterator.Value(), cost) + } + case reflect.String: + cost.Items++ + cost.TextBytes += uint64(len(value.String())) + } +} + +func isPresenceField(value reflect.Type) bool { + return value.PkgPath() == reflect.TypeOf(Field[string]{}).PkgPath() && strings.HasPrefix(value.Name(), "Field[") +} diff --git a/internal/kernel/requirementsourcemodel/projection_observation_test.go b/internal/kernel/requirementsourcemodel/projection_observation_test.go new file mode 100644 index 0000000..9cfa9e5 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/projection_observation_test.go @@ -0,0 +1,450 @@ +package requirementsourcemodel + +import "sort" + +type metadataObservation struct { + RequirementID string + OwnerKind MetadataOwnerKind + OwnerID string + Present bool + Value any +} + +func observeManifestField(model Model, fieldID string, projection string) (any, bool) { + atomic := model.Atomic() + layout := model.Layout() + references := model.References() + switch fieldID { + case "source.id": + switch projection { + case "atomic": + return atomic.SourceID, true + case "layout": + return layout.SourceID, true + case "references": + return references.SourceID, true + } + case "source.specPackagePath": + return atomic.SpecPackagePath, projection == "atomic" + case "source.nonClaimRefs": + switch projection { + case "atomic": + return atomic.SourceNonClaimRefs, true + case "references": + return observeEdges(references, ReferenceSourceNonClaim), true + } + case "nonClaim.id": + switch projection { + case "atomic": + return mapDefinitions(atomic, func(value NonClaimDefinition) any { return value.NonClaimID }), true + case "references": + return observeEdgesTo(references, EntityNonClaim), true + } + case "nonClaim.statement": + return mapDefinitions(atomic, func(value NonClaimDefinition) any { return value.Statement }), projection == "atomic" + case "vocabulary.id": + switch projection { + case "atomic": + return mapVocabulary(atomic, func(value VocabularyTerm) any { return value.TermID }), true + case "references": + return observeEdges(references, ReferenceScenarioVocabulary), true + } + case "vocabulary.kind": + return mapVocabulary(atomic, func(value VocabularyTerm) any { return value.Kind }), projection == "atomic" + case "vocabulary.label": + return mapVocabulary(atomic, func(value VocabularyTerm) any { return value.Label }), projection == "atomic" + case "vocabulary.definition": + return mapVocabulary(atomic, func(value VocabularyTerm) any { return value.Definition }), projection == "atomic" + case "derivation.id": + return mapDerivations(references, func(value Derivation) any { return value.DerivationID }), projection == "references" + case "derivation.sourceKind": + return mapDerivations(references, func(value Derivation) any { return value.SourceKind }), projection == "references" + case "derivation.sourceRef.objectFormat": + return mapDerivations(references, func(value Derivation) any { return value.SourceRef.ObjectFormat }), projection == "references" + case "derivation.sourceRef.commitOid": + return mapDerivations(references, func(value Derivation) any { return value.SourceRef.CommitOID }), projection == "references" + case "derivation.sourceRef.path": + return mapDerivations(references, func(value Derivation) any { return value.SourceRef.Path }), projection == "references" + case "derivation.sourceRef.sha256": + return mapDerivations(references, func(value Derivation) any { return value.SourceRef.SHA256 }), projection == "references" + case "derivation.selector.start": + return mapDerivations(references, func(value Derivation) any { return value.Selector.Start }), projection == "references" + case "derivation.selector.end": + return mapDerivations(references, func(value Derivation) any { return value.Selector.End }), projection == "references" + case "derivation.requirementIds": + return mapDerivations(references, func(value Derivation) any { return value.RequirementIDs }), projection == "references" + case "derivation.nonClaimRefs": + return mapDerivations(references, func(value Derivation) any { return value.NonClaimRefs }), projection == "references" + case "profile.id": + switch projection { + case "layout": + result := make([]string, len(layout.Profiles)) + for index, profile := range layout.Profiles { + result[index] = profile.ProfileID + } + return result, true + case "references": + return observeEdges(references, ReferenceGroupProfile), true + } + case "metadata.ownerId", "metadata.claimLevel", "metadata.riskClass", "metadata.nonClaimRefs", + "metadata.lifecycle.state", "metadata.lifecycle.replacementRequirementIds", "metadata.lifecycle.evidenceRefs", + "metadata.deferral.presence", "metadata.deferral.ownerId", "metadata.deferral.riskAcceptedBy", + "metadata.deferral.reviewCondition", "metadata.deferral.expiryRef", "metadata.deferral.mergePolicy", + "metadata.deferral.evidenceRefs", "metadata.updatePolicy.reviewOwnerId", + "metadata.updatePolicy.requiresImpactDeclaration", "metadata.updatePolicy.requiresProofBindingReview": + switch projection { + case "atomic": + return observeAtomicMetadata(atomic, fieldID), true + case "layout": + return observeLayoutMetadata(layout, fieldID), true + case "references": + switch fieldID { + case "metadata.nonClaimRefs": + return observeEdges(references, ReferenceRequirementNonClaim), true + case "metadata.lifecycle.replacementRequirementIds": + return observeEdges(references, ReferenceLifecycleReplacement), true + } + } + case "group.id": + switch projection { + case "layout": + return mapGroups(layout, func(value Group) any { return value.GroupID }), true + case "references": + return observeEdgesFrom(references, EntityGroup), true + } + case "group.memberRefs": + switch projection { + case "layout": + return observeLayoutGroupMembers(layout), true + case "references": + return observeEdges(references, ReferenceGroupMember), true + } + case "group.profileRef": + switch projection { + case "layout": + return mapGroups(layout, func(value Group) any { return value.ProfileID }), true + case "references": + return observeEdges(references, ReferenceGroupProfile), true + } + case "group.statementStem": + switch projection { + case "atomic": + return mapRequirements(atomic, func(value AtomicRequirement) any { return value.Invariant }), true + case "layout": + return mapGroups(layout, func(value Group) any { return value.StatementStem }), true + } + case "group.sharedPremises": + switch projection { + case "atomic": + return mapRequirements(atomic, func(value AtomicRequirement) any { return value.SharedPremises }), true + case "layout": + return mapGroups(layout, func(value Group) any { return value.SharedPremises }), true + } + case "member.requirementId": + switch projection { + case "atomic": + return mapRequirements(atomic, func(value AtomicRequirement) any { return value.RequirementID }), true + case "layout": + return mapMembers(layout, func(value Member) any { return value.RequirementID }), true + case "references": + return observeEdges(references, ReferenceGroupMember), true + } + case "member.statementCompletion": + switch projection { + case "atomic": + return mapRequirements(atomic, func(value AtomicRequirement) any { return value.Invariant }), true + case "layout": + return mapMembers(layout, func(value Member) any { return value.StatementCompletion }), true + } + case "scenario.id": + switch projection { + case "atomic": + return mapScenarios(atomic, func(value Scenario) any { return value.ScenarioID }), true + case "references": + return observeEdgesFrom(references, EntityScenario), true + } + case "scenario.requirementIds": + switch projection { + case "atomic": + return mapScenarios(atomic, func(value Scenario) any { return value.RequirementIDs }), true + case "references": + return observeEdges(references, ReferenceScenarioRequirement), true + } + case "scenario.parameters": + return mapScenarios(atomic, func(value Scenario) any { return value.Parameters }), projection == "atomic" + case "scenario.preconditions": + return mapScenarios(atomic, func(value Scenario) any { return value.Preconditions }), projection == "atomic" + case "scenario.actionSequence": + return mapScenarios(atomic, func(value Scenario) any { return value.ActionSequence }), projection == "atomic" + case "scenario.expectedObservations": + return mapScenarios(atomic, func(value Scenario) any { return value.ExpectedObservations }), projection == "atomic" + case "scenario.forbiddenObservations": + return mapScenarios(atomic, func(value Scenario) any { return value.ForbiddenObservations }), projection == "atomic" + case "scenario.example.id": + return mapExamples(atomic, func(value Example) any { return value.ExampleID }), projection == "atomic" + case "scenario.example.values": + return mapExamples(atomic, func(value Example) any { return value.Values }), projection == "atomic" + case "scenario.vocabularyRefs": + switch projection { + case "atomic": + return mapScenarios(atomic, func(value Scenario) any { return value.VocabularyRefs }), true + case "references": + return observeEdges(references, ReferenceScenarioVocabulary), true + } + case "scenario.nonClaimRefs": + switch projection { + case "atomic": + return mapScenarios(atomic, func(value Scenario) any { return value.NonClaimRefs }), true + case "references": + return observeEdges(references, ReferenceScenarioNonClaim), true + } + } + return nil, false +} + +func mapDefinitions(value AtomicProjection, project func(NonClaimDefinition) any) []any { + result := make([]any, len(value.NonClaimDefinitions)) + for index, item := range value.NonClaimDefinitions { + result[index] = project(item) + } + return result +} + +func mapVocabulary(value AtomicProjection, project func(VocabularyTerm) any) []any { + result := make([]any, len(value.Vocabulary)) + for index, item := range value.Vocabulary { + result[index] = project(item) + } + return result +} + +func mapDerivations(value ReferenceProjection, project func(Derivation) any) []any { + result := make([]any, len(value.Derivations)) + for index, item := range value.Derivations { + result[index] = project(item) + } + return result +} + +func mapRequirements(value AtomicProjection, project func(AtomicRequirement) any) []any { + result := make([]any, len(value.Requirements)) + for index, item := range value.Requirements { + result[index] = project(item) + } + return result +} + +func mapGroups(value LayoutProjection, project func(Group) any) []any { + result := make([]any, len(value.Groups)) + for index, item := range value.Groups { + result[index] = project(item) + } + return result +} + +func mapMembers(value LayoutProjection, project func(Member) any) []any { + result := []any{} + for _, group := range value.Groups { + for _, member := range group.Members { + result = append(result, project(member)) + } + } + return result +} + +func observeLayoutGroupMembers(value LayoutProjection) []string { + result := []string{} + for _, group := range value.Groups { + for _, member := range group.Members { + result = append(result, group.GroupID+"\x00"+member.RequirementID) + } + } + sort.Strings(result) + return result +} + +func mapScenarios(value AtomicProjection, project func(Scenario) any) []any { + result := make([]any, len(value.Scenarios)) + for index, item := range value.Scenarios { + result[index] = project(item) + } + return result +} + +func mapExamples(value AtomicProjection, project func(Example) any) []any { + result := []any{} + for _, scenario := range value.Scenarios { + for _, example := range scenario.Examples { + result = append(result, project(example)) + } + } + return result +} + +func observeEdges(value ReferenceProjection, kind ReferenceKind) []ReferenceEdge { + result := []ReferenceEdge{} + for _, edge := range value.Edges { + if edge.Kind == kind { + result = append(result, edge) + } + } + return result +} + +func observeEdgesFrom(value ReferenceProjection, kind EntityKind) []ReferenceEdge { + result := []ReferenceEdge{} + for _, edge := range value.Edges { + if edge.From.Kind == kind { + result = append(result, edge) + } + } + return result +} + +func observeEdgesTo(value ReferenceProjection, kind EntityKind) []ReferenceEdge { + result := []ReferenceEdge{} + for _, edge := range value.Edges { + if edge.To.Kind == kind { + result = append(result, edge) + } + } + return result +} + +func observeAtomicMetadata(value AtomicProjection, fieldID string) []any { + return mapRequirements(value, func(requirement AtomicRequirement) any { + switch fieldID { + case "metadata.ownerId": + return requirement.OwnerID + case "metadata.claimLevel": + return requirement.ClaimLevel + case "metadata.riskClass": + return requirement.RiskClass + case "metadata.nonClaimRefs": + return requirement.NonClaimRefs + case "metadata.lifecycle.state": + return requirement.Lifecycle.State + case "metadata.lifecycle.replacementRequirementIds": + return requirement.Lifecycle.ReplacementRequirementIDs + case "metadata.lifecycle.evidenceRefs": + return requirement.Lifecycle.EvidenceRefs + case "metadata.deferral.presence": + return requirement.Deferral != nil + case "metadata.deferral.ownerId": + return deferralField(requirement.Deferral, func(value *Deferral) any { return value.OwnerID }) + case "metadata.deferral.riskAcceptedBy": + return deferralField(requirement.Deferral, func(value *Deferral) any { return value.RiskAcceptedBy }) + case "metadata.deferral.reviewCondition": + return deferralField(requirement.Deferral, func(value *Deferral) any { return value.ReviewCondition }) + case "metadata.deferral.expiryRef": + return deferralField(requirement.Deferral, func(value *Deferral) any { return value.ExpiryRef }) + case "metadata.deferral.mergePolicy": + return deferralField(requirement.Deferral, func(value *Deferral) any { return value.MergePolicy }) + case "metadata.deferral.evidenceRefs": + return deferralField(requirement.Deferral, func(value *Deferral) any { return value.EvidenceRefs }) + case "metadata.updatePolicy.reviewOwnerId": + return requirement.UpdatePolicy.ReviewOwnerID + case "metadata.updatePolicy.requiresImpactDeclaration": + return requirement.UpdatePolicy.RequiresImpactDeclaration + case "metadata.updatePolicy.requiresProofBindingReview": + return requirement.UpdatePolicy.RequiresProofBindingReview + } + return nil + }) +} + +func observeLayoutMetadata(value LayoutProjection, fieldID string) []metadataObservation { + profiles := map[string]MetadataFields{} + for _, profile := range value.Profiles { + profiles[profile.ProfileID] = profile.Fields + } + result := []metadataObservation{} + for _, group := range value.Groups { + for _, member := range group.Members { + profileFields := profiles[group.ProfileID] + ownerKind := MetadataOwnerMember + ownerID := member.RequirementID + fields := member.Fields + if metadataFieldPresent(profileFields, fieldID) { + ownerKind = MetadataOwnerProfile + ownerID = group.ProfileID + fields = profileFields + } + present, fieldValue := metadataFieldValue(fields, fieldID) + result = append(result, metadataObservation{RequirementID: member.RequirementID, OwnerKind: ownerKind, OwnerID: ownerID, Present: present, Value: fieldValue}) + } + } + sort.Slice(result, func(left int, right int) bool { return result[left].RequirementID < result[right].RequirementID }) + return result +} + +func metadataFieldPresent(value MetadataFields, fieldID string) bool { + switch { + case fieldID == "metadata.ownerId": + return value.OwnerID.Present + case fieldID == "metadata.claimLevel": + return value.ClaimLevel.Present + case fieldID == "metadata.riskClass": + return value.RiskClass.Present + case fieldID == "metadata.nonClaimRefs": + return value.NonClaimRefs.Present + case fieldID == "metadata.lifecycle.state" || fieldID == "metadata.lifecycle.replacementRequirementIds" || fieldID == "metadata.lifecycle.evidenceRefs": + return value.Lifecycle.Present + case fieldID == "metadata.deferral.presence" || stringsHasPrefix(fieldID, "metadata.deferral."): + return value.Deferral.Present + case stringsHasPrefix(fieldID, "metadata.updatePolicy."): + return value.UpdatePolicy.Present + } + return false +} + +func metadataFieldValue(value MetadataFields, fieldID string) (bool, any) { + switch fieldID { + case "metadata.ownerId": + return value.OwnerID.Present, value.OwnerID.Value + case "metadata.claimLevel": + return value.ClaimLevel.Present, value.ClaimLevel.Value + case "metadata.riskClass": + return value.RiskClass.Present, value.RiskClass.Value + case "metadata.nonClaimRefs": + return value.NonClaimRefs.Present, value.NonClaimRefs.Value + case "metadata.lifecycle.state": + return value.Lifecycle.Present, value.Lifecycle.Value.State + case "metadata.lifecycle.replacementRequirementIds": + return value.Lifecycle.Present, value.Lifecycle.Value.ReplacementRequirementIDs + case "metadata.lifecycle.evidenceRefs": + return value.Lifecycle.Present, value.Lifecycle.Value.EvidenceRefs + case "metadata.deferral.presence": + return value.Deferral.Present, value.Deferral.Value != nil + case "metadata.deferral.ownerId": + return value.Deferral.Present, deferralField(value.Deferral.Value, func(item *Deferral) any { return item.OwnerID }) + case "metadata.deferral.riskAcceptedBy": + return value.Deferral.Present, deferralField(value.Deferral.Value, func(item *Deferral) any { return item.RiskAcceptedBy }) + case "metadata.deferral.reviewCondition": + return value.Deferral.Present, deferralField(value.Deferral.Value, func(item *Deferral) any { return item.ReviewCondition }) + case "metadata.deferral.expiryRef": + return value.Deferral.Present, deferralField(value.Deferral.Value, func(item *Deferral) any { return item.ExpiryRef }) + case "metadata.deferral.mergePolicy": + return value.Deferral.Present, deferralField(value.Deferral.Value, func(item *Deferral) any { return item.MergePolicy }) + case "metadata.deferral.evidenceRefs": + return value.Deferral.Present, deferralField(value.Deferral.Value, func(item *Deferral) any { return item.EvidenceRefs }) + case "metadata.updatePolicy.reviewOwnerId": + return value.UpdatePolicy.Present, value.UpdatePolicy.Value.ReviewOwnerID + case "metadata.updatePolicy.requiresImpactDeclaration": + return value.UpdatePolicy.Present, value.UpdatePolicy.Value.RequiresImpactDeclaration + case "metadata.updatePolicy.requiresProofBindingReview": + return value.UpdatePolicy.Present, value.UpdatePolicy.Value.RequiresProofBindingReview + } + return false, nil +} + +func deferralField(value *Deferral, project func(*Deferral) any) any { + if value == nil { + return nil + } + return project(value) +} + +func stringsHasPrefix(value string, prefix string) bool { + return len(value) >= len(prefix) && value[:len(prefix)] == prefix +} diff --git a/internal/kernel/requirementsourcemodel/reference_closure.go b/internal/kernel/requirementsourcemodel/reference_closure.go new file mode 100644 index 0000000..7e4ae0f --- /dev/null +++ b/internal/kernel/requirementsourcemodel/reference_closure.go @@ -0,0 +1,115 @@ +package requirementsourcemodel + +import "sort" + +func endpoint(kind EntityKind, id string) ReferenceEndpoint { + return ReferenceEndpoint{Kind: kind, ID: id} +} + +func buildReferenceEdges(sourceID string, sourceNonClaimRefs []string, groups []Group, requirements []AtomicRequirement, scenarios []Scenario, derivations []Derivation) []ReferenceEdge { + edges := make([]ReferenceEdge, 0) + for _, nonClaimID := range sourceNonClaimRefs { + edges = append(edges, ReferenceEdge{Kind: ReferenceSourceNonClaim, From: endpoint(EntitySource, sourceID), To: endpoint(EntityNonClaim, nonClaimID)}) + } + for _, group := range groups { + if group.ProfileID != "" { + edges = append(edges, ReferenceEdge{Kind: ReferenceGroupProfile, From: endpoint(EntityGroup, group.GroupID), To: endpoint(EntityProfile, group.ProfileID)}) + } + for _, member := range group.Members { + edges = append(edges, ReferenceEdge{Kind: ReferenceGroupMember, From: endpoint(EntityGroup, group.GroupID), To: endpoint(EntityRequirement, member.RequirementID)}) + } + } + for _, requirement := range requirements { + for _, nonClaimID := range requirement.NonClaimRefs { + edges = append(edges, ReferenceEdge{Kind: ReferenceRequirementNonClaim, From: endpoint(EntityRequirement, requirement.RequirementID), To: endpoint(EntityNonClaim, nonClaimID)}) + } + for _, replacementID := range requirement.Lifecycle.ReplacementRequirementIDs { + edges = append(edges, ReferenceEdge{Kind: ReferenceLifecycleReplacement, From: endpoint(EntityRequirement, requirement.RequirementID), To: endpoint(EntityRequirement, replacementID)}) + } + } + for _, scenario := range scenarios { + for _, requirementID := range scenario.RequirementIDs { + edges = append(edges, ReferenceEdge{Kind: ReferenceScenarioRequirement, From: endpoint(EntityScenario, scenario.ScenarioID), To: endpoint(EntityRequirement, requirementID)}) + } + for _, termID := range scenario.VocabularyRefs { + edges = append(edges, ReferenceEdge{Kind: ReferenceScenarioVocabulary, From: endpoint(EntityScenario, scenario.ScenarioID), To: endpoint(EntityTerm, termID)}) + } + for _, nonClaimID := range scenario.NonClaimRefs { + edges = append(edges, ReferenceEdge{Kind: ReferenceScenarioNonClaim, From: endpoint(EntityScenario, scenario.ScenarioID), To: endpoint(EntityNonClaim, nonClaimID)}) + } + } + for _, derivation := range derivations { + for _, requirementID := range derivation.RequirementIDs { + edges = append(edges, ReferenceEdge{Kind: ReferenceDerivationRequirement, From: endpoint(EntityDerivation, derivation.DerivationID), To: endpoint(EntityRequirement, requirementID)}) + } + for _, nonClaimID := range derivation.NonClaimRefs { + edges = append(edges, ReferenceEdge{Kind: ReferenceDerivationNonClaim, From: endpoint(EntityDerivation, derivation.DerivationID), To: endpoint(EntityNonClaim, nonClaimID)}) + } + } + sort.Slice(edges, func(left int, right int) bool { + if edges[left].Kind != edges[right].Kind { + return edges[left].Kind < edges[right].Kind + } + if edges[left].From.Kind != edges[right].From.Kind { + return edges[left].From.Kind < edges[right].From.Kind + } + if edges[left].From.ID != edges[right].From.ID { + return edges[left].From.ID < edges[right].From.ID + } + if edges[left].To.Kind != edges[right].To.Kind { + return edges[left].To.Kind < edges[right].To.Kind + } + return edges[left].To.ID < edges[right].To.ID + }) + return edges +} + +func validateReferenceClosure(definitions map[string]struct{}, vocabulary map[string]struct{}, edges []ReferenceEdge) error { + usedDefinitions := map[string]struct{}{} + usedVocabulary := map[string]struct{}{} + seenEdges := map[ReferenceEdge]struct{}{} + for _, edge := range edges { + if _, exists := seenEdges[edge]; exists { + return invalid("duplicate_reference_edge", "references") + } + seenEdges[edge] = struct{}{} + switch edge.Kind { + case ReferenceSourceNonClaim, ReferenceRequirementNonClaim, ReferenceScenarioNonClaim, ReferenceDerivationNonClaim: + if edge.To.Kind != EntityNonClaim { + return invalid("invalid_reference_role", "references") + } + if _, exists := definitions[edge.To.ID]; !exists { + return invalid("dangling_nonclaim_ref", "references") + } + usedDefinitions[edge.To.ID] = struct{}{} + case ReferenceScenarioVocabulary: + if edge.To.Kind != EntityTerm { + return invalid("invalid_reference_role", "references") + } + if _, exists := vocabulary[edge.To.ID]; !exists { + return invalid("dangling_vocabulary_ref", "references") + } + usedVocabulary[edge.To.ID] = struct{}{} + } + } + for _, definitionID := range sortedSetKeys(definitions) { + if _, exists := usedDefinitions[definitionID]; !exists { + return invalid("unreferenced_definition", "nonClaimDefinitions."+definitionID) + } + } + for _, termID := range sortedSetKeys(vocabulary) { + if _, exists := usedVocabulary[termID]; !exists { + return invalid("unreferenced_vocabulary", "vocabulary."+termID) + } + } + return nil +} + +func sortedSetKeys(values map[string]struct{}) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/kernel/requirementsourcemodel/scenario_evaluation.go b/internal/kernel/requirementsourcemodel/scenario_evaluation.go new file mode 100644 index 0000000..a472474 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/scenario_evaluation.go @@ -0,0 +1,114 @@ +package requirementsourcemodel + +type scenarioEvaluationCost struct { + Items uint64 + TextBytes uint64 +} + +func preflightScenarioEvaluationBudget(draft Draft, limits Limits) error { + cost, overflow := estimateScenarioEvaluationCost(draft) + if overflow || cost.Items > uint64(limits.MaxScenarioEvaluations) { + return invalid("scenario_evaluation_budget_exceeded", "scenarios") + } + if cost.TextBytes > uint64(limits.MaxScenarioEvaluationBytes) { + return invalid("scenario_evaluation_text_budget_exceeded", "scenarios") + } + return nil +} + +func estimateScenarioEvaluationCost(draft Draft) (scenarioEvaluationCost, bool) { + cost := scenarioEvaluationCost{} + for _, scenario := range draft.Scenarios { + exampleCount := uint64(len(scenario.Examples)) + observationCount := uint64(len(scenario.ExpectedObservations) + len(scenario.ForbiddenObservations)) + if !addScenarioCost(&cost.Items, observationCount, exampleCount) { + return cost, true + } + valueBytes := map[string]uint64{} + valueCounts := map[string]uint64{} + for _, example := range scenario.Examples { + for parameter, value := range example.Values { + bytes := valueBytes[parameter] + if !addScenarioCost(&bytes, uint64(len(value)), 1) { + return cost, true + } + valueBytes[parameter] = bytes + valueCounts[parameter]++ + } + } + observations := append([]string{}, scenario.ExpectedObservations...) + observations = append(observations, scenario.ForbiddenObservations...) + for _, observation := range observations { + bytes, overflow := instantiatedObservationBytes(observation, exampleCount, valueBytes, valueCounts) + if overflow || !addScenarioCost(&cost.TextBytes, bytes, 1) { + return cost, true + } + } + } + return cost, false +} + +func instantiatedObservationBytes(template string, exampleCount uint64, valueBytes map[string]uint64, valueCounts map[string]uint64) (uint64, bool) { + matches := parameterReferencePattern.FindAllStringSubmatchIndex(template, -1) + staticBytes := uint64(len(template)) + for _, match := range matches { + staticBytes -= uint64(match[1] - match[0]) + } + total := uint64(0) + if !addScenarioCost(&total, staticBytes, exampleCount) { + return 0, true + } + for _, match := range matches { + parameter := template[match[2]:match[3]] + if !addScenarioCost(&total, valueBytes[parameter], 1) { + return 0, true + } + missingCount := exampleCount - valueCounts[parameter] + if !addScenarioCost(&total, uint64(match[1]-match[0]), missingCount) { + return 0, true + } + } + return total, false +} + +func addScenarioCost(total *uint64, value uint64, multiplier uint64) bool { + maximum := ^uint64(0) + if multiplier != 0 && value > maximum/multiplier { + return false + } + amount := value * multiplier + if amount > maximum-*total { + return false + } + *total += amount + return true +} + +func validateInstantiatedObservationDisjoint(expected []string, forbidden []string, examples []Example, path string) error { + for _, example := range examples { + expectedValues := make(map[string]struct{}, len(expected)) + for _, observation := range expected { + expectedValues[instantiateScenarioObservation(observation, example.Values)] = struct{}{} + } + for _, observation := range forbidden { + if _, contradiction := expectedValues[instantiateScenarioObservation(observation, example.Values)]; contradiction { + return invalid("contradictory_instantiated_observation", path) + } + } + } + return nil +} + +func instantiateScenarioObservation(template string, values map[string]ScenarioValue) string { + return parameterReferencePattern.ReplaceAllStringFunc(template, func(reference string) string { + matches := parameterReferencePattern.FindStringSubmatch(reference) + if len(matches) != 2 { + return reference + } + value, exists := values[matches[1]] + if !exists { + return reference + } + return string(value) + }) +} diff --git a/internal/kernel/requirementsourcemodel/scenario_evaluation_test.go b/internal/kernel/requirementsourcemodel/scenario_evaluation_test.go new file mode 100644 index 0000000..19b33b4 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/scenario_evaluation_test.go @@ -0,0 +1,88 @@ +package requirementsourcemodel + +import ( + "os" + "testing" +) + +func TestNormalizeRejectsContradictoryInstantiatedObservations(t *testing.T) { + draft := validDraft() + draft.Scenarios[0].ExpectedObservations = []string{"The ${surface} request is accepted."} + draft.Scenarios[0].ForbiddenObservations = []string{"The primary request is accepted."} + + _, err := Normalize(draft) + if ErrorCode(err) != "contradictory_instantiated_observation" { + t.Fatalf("ErrorCode() = %q, error = %v", ErrorCode(err), err) + } +} + +func TestScenarioEvaluationBudgetMatchesIndependentInstantiation(t *testing.T) { + draft := validDraft() + scenario := &draft.Scenarios[0] + scenario.ExpectedObservations = []string{ + "The ${surface} request is accepted.", + "The response identifies ${surface}.", + } + scenario.ForbiddenObservations = []string{"The ${surface} request is rejected."} + + estimated, overflow := estimateScenarioEvaluationCost(draft) + if overflow { + t.Fatal("valid scenario evaluation overflowed") + } + observed := observeScenarioEvaluationCost(draft) + if estimated != observed { + t.Fatalf("estimated scenario evaluation cost = %#v, observed = %#v", estimated, observed) + } + + limits := DefaultLimits() + limits.MaxScenarioEvaluations = int(observed.Items) + limits.MaxScenarioEvaluationBytes = int(observed.TextBytes) + if _, err := NormalizeWithLimits(draft, limits); err != nil { + t.Fatalf("exact scenario evaluation budgets rejected: %v", err) + } + + itemLimits := DefaultLimits() + itemLimits.MaxScenarioEvaluations = int(observed.Items - 1) + if _, err := NormalizeWithLimits(draft, itemLimits); ErrorCode(err) != "scenario_evaluation_budget_exceeded" { + t.Fatalf("item limit-1 ErrorCode() = %q, error = %v", ErrorCode(err), err) + } + textLimits := DefaultLimits() + textLimits.MaxScenarioEvaluationBytes = int(observed.TextBytes - 1) + if _, err := NormalizeWithLimits(draft, textLimits); ErrorCode(err) != "scenario_evaluation_text_budget_exceeded" { + t.Fatalf("text limit-1 ErrorCode() = %q, error = %v", ErrorCode(err), err) + } +} + +func TestScenarioParameterValuesAreSubstitutedOnce(t *testing.T) { + draft := validDraft() + scenario := &draft.Scenarios[0] + scenario.Parameters = []string{"nested", "surface"} + scenario.ExpectedObservations = []string{"Observed ${surface}."} + scenario.ForbiddenObservations = []string{"Observed ${nested}."} + scenario.Examples = []Example{ + {ExampleID: "EX-MODEL-REQUEST-001", Values: map[string]ScenarioValue{"nested": "resolved", "surface": "${nested}"}}, + {ExampleID: "EX-MODEL-REQUEST-002", Values: map[string]ScenarioValue{"nested": "other", "surface": "secondary"}}, + } + + if _, err := Normalize(draft); err != nil { + t.Fatalf("single-pass scenario substitution rejected: %v", err) + } +} + +func observeScenarioEvaluationCost(draft Draft) scenarioEvaluationCost { + cost := scenarioEvaluationCost{} + for _, scenario := range draft.Scenarios { + observations := append([]string{}, scenario.ExpectedObservations...) + observations = append(observations, scenario.ForbiddenObservations...) + for _, example := range scenario.Examples { + for _, observation := range observations { + instantiated := os.Expand(observation, func(parameter string) string { + return string(example.Values[parameter]) + }) + cost.Items++ + cost.TextBytes += uint64(len(instantiated)) + } + } + } + return cost +} diff --git a/internal/kernel/requirementsourcemodel/scenario_normalization.go b/internal/kernel/requirementsourcemodel/scenario_normalization.go new file mode 100644 index 0000000..b132595 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/scenario_normalization.go @@ -0,0 +1,201 @@ +package requirementsourcemodel + +import ( + "sort" + "strings" +) + +func normalizeScenarios(values []Scenario, requirements map[string]AtomicRequirement, vocabulary map[string]struct{}) ([]Scenario, error) { + result := make([]Scenario, len(values)) + ids := make(map[string]struct{}, len(values)) + for index, value := range values { + path := indexed("scenarios", index, "") + scenarioID, err := canonicalID(value.ScenarioID, "SCN-", path+"scenarioId") + if err != nil { + return nil, err + } + if _, exists := ids[scenarioID]; exists { + return nil, invalid("duplicate_id", "scenarios") + } + requirementIDs, err := normalizeIDs(value.RequirementIDs, "REQ-", path+"requirementIds", false) + if err != nil { + return nil, err + } + for _, requirementID := range requirementIDs { + if _, exists := requirements[requirementID]; !exists { + return nil, invalid("dangling_requirement_ref", path+"requirementIds") + } + } + parameters, err := normalizeParameters(value.Parameters, path+"parameters") + if err != nil { + return nil, err + } + preconditions, err := normalizeTexts(value.Preconditions, path+"preconditions", false, true) + if err != nil { + return nil, err + } + actions, err := normalizeOrderedTexts(value.ActionSequence, path+"actionSequence", false) + if err != nil { + return nil, err + } + expected, err := normalizeTexts(value.ExpectedObservations, path+"expectedObservations", false, true) + if err != nil { + return nil, err + } + forbidden, err := normalizeTexts(value.ForbiddenObservations, path+"forbiddenObservations", true, true) + if err != nil { + return nil, err + } + if sortedStringsIntersect(expected, forbidden) { + return nil, invalid("contradictory_observation", path+"expectedObservations") + } + vocabularyRefs, err := normalizeIDs(value.VocabularyRefs, "TERM-", path+"vocabularyRefs", true) + if err != nil { + return nil, err + } + for _, termID := range vocabularyRefs { + if _, exists := vocabulary[termID]; !exists { + return nil, invalid("dangling_vocabulary_ref", path+"vocabularyRefs") + } + } + nonClaimRefs, err := normalizeIDs(value.NonClaimRefs, "NCL-", path+"nonClaimRefs", true) + if err != nil { + return nil, err + } + statements := append([]string{}, preconditions...) + statements = append(statements, actions...) + statements = append(statements, expected...) + statements = append(statements, forbidden...) + if err := validateParameterReferences(parameters, statements, path); err != nil { + return nil, err + } + examples, err := normalizeExamples(value.Examples, parameters, path+"examples") + if err != nil { + return nil, err + } + if err := validateInstantiatedObservationDisjoint(expected, forbidden, examples, path+"expectedObservations"); err != nil { + return nil, err + } + ids[scenarioID] = struct{}{} + result[index] = Scenario{ + ScenarioID: scenarioID, + RequirementIDs: requirementIDs, + Parameters: parameters, + Preconditions: preconditions, + ActionSequence: actions, + ExpectedObservations: expected, + ForbiddenObservations: forbidden, + Examples: examples, + VocabularyRefs: vocabularyRefs, + NonClaimRefs: nonClaimRefs, + } + } + sort.Slice(result, func(left int, right int) bool { return result[left].ScenarioID < result[right].ScenarioID }) + return result, nil +} + +func sortedStringsIntersect(left []string, right []string) bool { + for leftIndex, rightIndex := 0, 0; leftIndex < len(left) && rightIndex < len(right); { + switch { + case left[leftIndex] < right[rightIndex]: + leftIndex++ + case left[leftIndex] > right[rightIndex]: + rightIndex++ + default: + return true + } + } + return false +} + +func normalizeParameters(values []string, path string) ([]string, error) { + result := make([]string, len(values)) + for index, value := range values { + if !parameterPattern.MatchString(value) { + return nil, invalid("invalid_parameter", path) + } + result[index] = value + } + return sortUnique(result, path) +} + +func validateParameterReferences(parameters []string, statements []string, path string) error { + parameterSet := make(map[string]struct{}, len(parameters)) + used := make(map[string]struct{}, len(parameters)) + for _, parameter := range parameters { + parameterSet[parameter] = struct{}{} + } + for _, statement := range statements { + matches := parameterReferencePattern.FindAllStringSubmatch(statement, -1) + for _, match := range matches { + parameter := match[1] + if _, exists := parameterSet[parameter]; !exists { + return invalid("unknown_parameter_ref", path) + } + used[parameter] = struct{}{} + } + withoutKnownRefs := parameterReferencePattern.ReplaceAllString(statement, "") + if strings.Contains(withoutKnownRefs, "${") { + return invalid("invalid_parameter_ref", path) + } + } + for _, parameter := range parameters { + if _, exists := used[parameter]; !exists { + return invalid("unused_parameter", path) + } + } + return nil +} + +func normalizeExamples(values []Example, parameters []string, path string) ([]Example, error) { + if len(parameters) == 0 && len(values) != 0 { + return nil, invalid("unexpected_examples", path) + } + if len(parameters) != 0 && len(values) < 2 { + return nil, invalid("insufficient_examples", path) + } + result := make([]Example, len(values)) + ids := make(map[string]struct{}, len(values)) + for index, value := range values { + examplePath := indexed(path, index, "") + exampleID, err := canonicalID(value.ExampleID, "EX-", examplePath+"exampleId") + if err != nil { + return nil, err + } + if _, exists := ids[exampleID]; exists { + return nil, invalid("duplicate_id", path) + } + keys := make([]string, 0, len(value.Values)) + for key := range value.Values { + keys = append(keys, key) + } + sort.Strings(keys) + if !equalStrings(keys, parameters) { + return nil, invalid("example_parameter_mismatch", examplePath+"values") + } + items := make(map[string]ScenarioValue, len(keys)) + for _, key := range keys { + scalar, err := canonicalText(string(value.Values[key]), examplePath+"values."+key, false, false) + if err != nil { + return nil, err + } + items[key] = ScenarioValue(scalar) + } + ids[exampleID] = struct{}{} + result[index] = Example{ExampleID: exampleID, Values: items} + } + sort.Slice(result, func(left int, right int) bool { return result[left].ExampleID < result[right].ExampleID }) + return result, nil +} + +func equalStrings(left []string, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} diff --git a/internal/kernel/requirementsourcemodel/testdata/field-projection-manifest.v1.json b/internal/kernel/requirementsourcemodel/testdata/field-projection-manifest.v1.json new file mode 100644 index 0000000..a4f0cb6 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/testdata/field-projection-manifest.v1.json @@ -0,0 +1,85 @@ +{ + "schemaVersion": 1, + "kind": "proofkit.requirement-source-model-field-projection-manifest", + "projections": [ + "atomic", + "layout", + "references" + ], + "fields": [ + {"fieldId":"source.id","requiredProjections":["atomic","layout","references"]}, + {"fieldId":"source.specPackagePath","requiredProjections":["atomic"]}, + {"fieldId":"source.nonClaimRefs","requiredProjections":["atomic","references"]}, + {"fieldId":"nonClaim.id","requiredProjections":["atomic","references"]}, + {"fieldId":"nonClaim.statement","requiredProjections":["atomic"]}, + {"fieldId":"vocabulary.id","requiredProjections":["atomic","references"]}, + {"fieldId":"vocabulary.kind","requiredProjections":["atomic"]}, + {"fieldId":"vocabulary.label","requiredProjections":["atomic"]}, + {"fieldId":"vocabulary.definition","requiredProjections":["atomic"]}, + {"fieldId":"derivation.id","requiredProjections":["references"]}, + {"fieldId":"derivation.sourceKind","requiredProjections":["references"]}, + {"fieldId":"derivation.sourceRef.objectFormat","requiredProjections":["references"]}, + {"fieldId":"derivation.sourceRef.commitOid","requiredProjections":["references"]}, + {"fieldId":"derivation.sourceRef.path","requiredProjections":["references"]}, + {"fieldId":"derivation.sourceRef.sha256","requiredProjections":["references"]}, + {"fieldId":"derivation.selector.start","requiredProjections":["references"]}, + {"fieldId":"derivation.selector.end","requiredProjections":["references"]}, + {"fieldId":"derivation.requirementIds","requiredProjections":["references"]}, + {"fieldId":"derivation.nonClaimRefs","requiredProjections":["references"]}, + {"fieldId":"profile.id","requiredProjections":["layout","references"]}, + {"fieldId":"metadata.ownerId","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.claimLevel","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.riskClass","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.nonClaimRefs","requiredProjections":["atomic","layout","references"]}, + {"fieldId":"metadata.lifecycle.state","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.lifecycle.replacementRequirementIds","requiredProjections":["atomic","layout","references"]}, + {"fieldId":"metadata.lifecycle.evidenceRefs","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.deferral.presence","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.deferral.ownerId","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.deferral.riskAcceptedBy","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.deferral.reviewCondition","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.deferral.expiryRef","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.deferral.mergePolicy","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.deferral.evidenceRefs","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.updatePolicy.reviewOwnerId","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.updatePolicy.requiresImpactDeclaration","requiredProjections":["atomic","layout"]}, + {"fieldId":"metadata.updatePolicy.requiresProofBindingReview","requiredProjections":["atomic","layout"]}, + {"fieldId":"group.id","requiredProjections":["layout","references"]}, + {"fieldId":"group.memberRefs","requiredProjections":["layout","references"]}, + {"fieldId":"group.profileRef","requiredProjections":["layout","references"]}, + {"fieldId":"group.statementStem","requiredProjections":["atomic","layout"]}, + {"fieldId":"group.sharedPremises","requiredProjections":["atomic","layout"]}, + {"fieldId":"member.requirementId","requiredProjections":["atomic","layout","references"]}, + {"fieldId":"member.statementCompletion","requiredProjections":["atomic","layout"]}, + {"fieldId":"scenario.id","requiredProjections":["atomic","references"]}, + {"fieldId":"scenario.requirementIds","requiredProjections":["atomic","references"]}, + {"fieldId":"scenario.parameters","requiredProjections":["atomic"]}, + {"fieldId":"scenario.preconditions","requiredProjections":["atomic"]}, + {"fieldId":"scenario.actionSequence","requiredProjections":["atomic"]}, + {"fieldId":"scenario.expectedObservations","requiredProjections":["atomic"]}, + {"fieldId":"scenario.forbiddenObservations","requiredProjections":["atomic"]}, + {"fieldId":"scenario.example.id","requiredProjections":["atomic"]}, + {"fieldId":"scenario.example.values","requiredProjections":["atomic"]}, + {"fieldId":"scenario.vocabularyRefs","requiredProjections":["atomic","references"]}, + {"fieldId":"scenario.nonClaimRefs","requiredProjections":["atomic","references"]} + ], + "variants": [ + {"variantId":"claimLevel","values":["advisory","blocking","deferred"],"requiredProjections":["atomic","layout"]}, + {"variantId":"riskClass","values":["critical","high","low","medium"],"requiredProjections":["atomic","layout"]}, + {"variantId":"lifecycleState","values":["active","deprecated","removed","superseded"],"requiredProjections":["atomic","layout"]}, + {"variantId":"termKind","values":["action","observable","state","subject","value"],"requiredProjections":["atomic"]}, + {"variantId":"sourceKind","values":["clarification","code_snapshot","design","owner_decision","plan"],"requiredProjections":["references"]}, + {"variantId":"objectFormat","values":["sha1","sha256"],"requiredProjections":["references"]}, + {"variantId":"entityKind","values":["derivation","group","nonclaim","profile","requirement","scenario","source","term"],"requiredProjections":["references"]}, + {"variantId":"referenceKind","values":["derivation_nonclaim","derivation_requirement","group_member","group_profile","lifecycle_replacement","requirement_nonclaim","scenario_nonclaim","scenario_requirement","scenario_vocabulary","source_nonclaim"],"requiredProjections":["references"]}, + {"variantId":"metadataOwner","values":["member","profile"],"requiredProjections":["layout"]}, + {"variantId":"deferral","values":["explicit_null","record"],"requiredProjections":["atomic","layout"]}, + {"variantId":"profileRef","values":["absent","present"],"requiredProjections":["layout","references"]}, + {"variantId":"scenarioValue","values":["string"],"requiredProjections":["atomic"]} + ], + "nonClaims": [ + "Projection sensitivity does not prove that a future consumer uses a field correctly.", + "The manifest does not prove requirement meaning, implementation correctness, or downstream public cutover.", + "The manifest does not select or validate a source codec." + ] +} diff --git a/internal/kernel/requirementsourcemodel/testdata/model-mutants.v1.json b/internal/kernel/requirementsourcemodel/testdata/model-mutants.v1.json new file mode 100644 index 0000000..576561b --- /dev/null +++ b/internal/kernel/requirementsourcemodel/testdata/model-mutants.v1.json @@ -0,0 +1,65 @@ +{ + "schemaVersion": 1, + "kind": "proofkit.requirement-source-model-mutant-corpus", + "mutants": [ + {"mutantId":"source-id","mutatedFields":["source.id"],"provenFields":["source.id"],"relationIds":[],"changedProjections":["atomic","layout","references"]}, + {"mutantId":"source-package-path","mutatedFields":["source.specPackagePath"],"provenFields":["source.specPackagePath"],"relationIds":[],"changedProjections":["atomic"]}, + {"mutantId":"source-nonclaim-refs","mutatedFields":["source.nonClaimRefs"],"provenFields":["source.nonClaimRefs"],"relationIds":[],"changedProjections":["atomic","references"]}, + {"mutantId":"nonclaim-id","mutatedFields":["nonClaim.id","source.nonClaimRefs"],"provenFields":[],"relationIds":["nonclaim-reference-rename"],"changedProjections":["atomic","references"]}, + {"mutantId":"nonclaim-statement","mutatedFields":["nonClaim.statement"],"provenFields":["nonClaim.statement"],"relationIds":[],"changedProjections":["atomic"]}, + {"mutantId":"vocabulary-id","mutatedFields":["scenario.vocabularyRefs","vocabulary.id"],"provenFields":[],"relationIds":["vocabulary-reference-rename"],"changedProjections":["atomic","references"]}, + {"mutantId":"vocabulary-kind","mutatedFields":["vocabulary.kind"],"provenFields":["vocabulary.kind"],"relationIds":[],"changedProjections":["atomic"]}, + {"mutantId":"vocabulary-label","mutatedFields":["vocabulary.label"],"provenFields":["vocabulary.label"],"relationIds":[],"changedProjections":["atomic"]}, + {"mutantId":"vocabulary-definition","mutatedFields":["vocabulary.definition"],"provenFields":["vocabulary.definition"],"relationIds":[],"changedProjections":["atomic"]}, + {"mutantId":"derivation-id","mutatedFields":["derivation.id"],"provenFields":["derivation.id"],"relationIds":[],"changedProjections":["references"]}, + {"mutantId":"derivation-source-kind","mutatedFields":["derivation.sourceKind"],"provenFields":["derivation.sourceKind"],"relationIds":[],"changedProjections":["references"]}, + {"mutantId":"derivation-object-format","mutatedFields":["derivation.sourceRef.commitOid","derivation.sourceRef.objectFormat"],"provenFields":[],"relationIds":["object-format-commit-identity"],"changedProjections":["references"]}, + {"mutantId":"derivation-commit-oid","mutatedFields":["derivation.sourceRef.commitOid"],"provenFields":["derivation.sourceRef.commitOid"],"relationIds":[],"changedProjections":["references"]}, + {"mutantId":"derivation-path","mutatedFields":["derivation.sourceRef.path"],"provenFields":["derivation.sourceRef.path"],"relationIds":[],"changedProjections":["references"]}, + {"mutantId":"derivation-sha256","mutatedFields":["derivation.sourceRef.sha256"],"provenFields":["derivation.sourceRef.sha256"],"relationIds":[],"changedProjections":["references"]}, + {"mutantId":"derivation-selector-start","mutatedFields":["derivation.selector.start"],"provenFields":["derivation.selector.start"],"relationIds":[],"changedProjections":["references"]}, + {"mutantId":"derivation-selector-end","mutatedFields":["derivation.selector.end"],"provenFields":["derivation.selector.end"],"relationIds":[],"changedProjections":["references"]}, + {"mutantId":"derivation-requirements","mutatedFields":["derivation.requirementIds"],"provenFields":["derivation.requirementIds"],"relationIds":[],"changedProjections":["references"]}, + {"mutantId":"derivation-nonclaims","mutatedFields":["derivation.nonClaimRefs"],"provenFields":["derivation.nonClaimRefs"],"relationIds":[],"changedProjections":["references"]}, + {"mutantId":"profile-id","mutatedFields":["group.profileRef","profile.id"],"provenFields":[],"relationIds":["profile-reference-rename"],"changedProjections":["layout","references"]}, + {"mutantId":"metadata-owner","mutatedFields":["metadata.ownerId"],"provenFields":["metadata.ownerId"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-claim-level","mutatedFields":["metadata.claimLevel"],"provenFields":["metadata.claimLevel"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-risk-class","mutatedFields":["metadata.riskClass"],"provenFields":["metadata.riskClass"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-nonclaims","mutatedFields":["metadata.nonClaimRefs"],"provenFields":["metadata.nonClaimRefs"],"relationIds":[],"changedProjections":["atomic","layout","references"]}, + {"mutantId":"metadata-lifecycle-state","mutatedFields":["metadata.lifecycle.evidenceRefs","metadata.lifecycle.state"],"provenFields":[],"relationIds":["lifecycle-state-evidence"],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-lifecycle-replacements","mutatedFields":["metadata.lifecycle.replacementRequirementIds"],"provenFields":["metadata.lifecycle.replacementRequirementIds"],"relationIds":[],"changedProjections":["atomic","layout","references"]}, + {"mutantId":"metadata-lifecycle-evidence","mutatedFields":["metadata.lifecycle.evidenceRefs"],"provenFields":["metadata.lifecycle.evidenceRefs"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-deferral-presence","mutatedFields":["metadata.claimLevel","metadata.deferral.evidenceRefs","metadata.deferral.expiryRef","metadata.deferral.mergePolicy","metadata.deferral.ownerId","metadata.deferral.presence","metadata.deferral.reviewCondition","metadata.deferral.riskAcceptedBy"],"provenFields":[],"relationIds":["claim-deferral-presence"],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-deferral-owner","mutatedFields":["metadata.deferral.ownerId"],"provenFields":["metadata.deferral.ownerId"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-deferral-risk-owner","mutatedFields":["metadata.deferral.riskAcceptedBy"],"provenFields":["metadata.deferral.riskAcceptedBy"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-deferral-review","mutatedFields":["metadata.deferral.reviewCondition"],"provenFields":["metadata.deferral.reviewCondition"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-deferral-expiry","mutatedFields":["metadata.deferral.expiryRef"],"provenFields":["metadata.deferral.expiryRef"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-deferral-policy","mutatedFields":["metadata.deferral.mergePolicy"],"provenFields":["metadata.deferral.mergePolicy"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-deferral-evidence","mutatedFields":["metadata.deferral.evidenceRefs"],"provenFields":["metadata.deferral.evidenceRefs"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-update-owner","mutatedFields":["metadata.updatePolicy.reviewOwnerId"],"provenFields":["metadata.updatePolicy.reviewOwnerId"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-update-impact","mutatedFields":["metadata.updatePolicy.requiresImpactDeclaration"],"provenFields":["metadata.updatePolicy.requiresImpactDeclaration"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"metadata-update-binding","mutatedFields":["metadata.updatePolicy.requiresProofBindingReview"],"provenFields":["metadata.updatePolicy.requiresProofBindingReview"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"group-id","mutatedFields":["group.id","group.memberRefs"],"provenFields":[],"relationIds":["group-identity-member-reference"],"changedProjections":["layout","references"]}, + {"mutantId":"group-membership","mutatedFields":["group.memberRefs"],"provenFields":[],"relationIds":["group-member-reassignment"],"changedProjections":["layout","references"]}, + {"mutantId":"group-profile-ref","mutatedFields":["group.profileRef","metadata.claimLevel","metadata.ownerId","metadata.riskClass","metadata.updatePolicy.requiresImpactDeclaration","metadata.updatePolicy.requiresProofBindingReview","metadata.updatePolicy.reviewOwnerId","profile.id"],"provenFields":[],"relationIds":["profile-removal-ownership-migration"],"changedProjections":["layout","references"]}, + {"mutantId":"group-stem","mutatedFields":["group.statementStem"],"provenFields":["group.statementStem"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"group-premises","mutatedFields":["group.sharedPremises"],"provenFields":["group.sharedPremises"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"member-id","mutatedFields":["derivation.requirementIds","group.memberRefs","member.requirementId"],"provenFields":[],"relationIds":["requirement-reference-rename"],"changedProjections":["atomic","layout","references"]}, + {"mutantId":"member-completion","mutatedFields":["member.statementCompletion"],"provenFields":["member.statementCompletion"],"relationIds":[],"changedProjections":["atomic","layout"]}, + {"mutantId":"scenario-id","mutatedFields":["scenario.id"],"provenFields":["scenario.id"],"relationIds":[],"changedProjections":["atomic","references"]}, + {"mutantId":"scenario-requirements","mutatedFields":["scenario.requirementIds"],"provenFields":["scenario.requirementIds"],"relationIds":[],"changedProjections":["atomic","references"]}, + {"mutantId":"scenario-parameters","mutatedFields":["scenario.example.values","scenario.parameters","scenario.preconditions"],"provenFields":[],"relationIds":["scenario-parameter-rename"],"changedProjections":["atomic"]}, + {"mutantId":"scenario-preconditions","mutatedFields":["scenario.preconditions"],"provenFields":["scenario.preconditions"],"relationIds":[],"changedProjections":["atomic"]}, + {"mutantId":"scenario-actions","mutatedFields":["scenario.actionSequence"],"provenFields":["scenario.actionSequence"],"relationIds":[],"changedProjections":["atomic"]}, + {"mutantId":"scenario-expected","mutatedFields":["scenario.expectedObservations"],"provenFields":["scenario.expectedObservations"],"relationIds":[],"changedProjections":["atomic"]}, + {"mutantId":"scenario-forbidden","mutatedFields":["scenario.forbiddenObservations"],"provenFields":["scenario.forbiddenObservations"],"relationIds":[],"changedProjections":["atomic"]}, + {"mutantId":"scenario-example-id","mutatedFields":["scenario.example.id"],"provenFields":["scenario.example.id"],"relationIds":[],"changedProjections":["atomic"]}, + {"mutantId":"scenario-example-values","mutatedFields":["scenario.example.values"],"provenFields":["scenario.example.values"],"relationIds":[],"changedProjections":["atomic"]}, + {"mutantId":"scenario-vocabulary","mutatedFields":["scenario.vocabularyRefs","vocabulary.definition","vocabulary.id","vocabulary.kind","vocabulary.label"],"provenFields":[],"relationIds":["scenario-vocabulary-extension"],"changedProjections":["atomic","references"]}, + {"mutantId":"scenario-nonclaims","mutatedFields":["scenario.nonClaimRefs"],"provenFields":["scenario.nonClaimRefs"],"relationIds":[],"changedProjections":["atomic","references"]} + ], + "nonClaims": [ + "Mutants prove projection sensitivity for the private model only.", + "Relation mutants prove only the named admitted relation and never independent causality for a correlated field edit." + ] +} diff --git a/internal/kernel/requirementsourcemodel/types.go b/internal/kernel/requirementsourcemodel/types.go new file mode 100644 index 0000000..17257a7 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/types.go @@ -0,0 +1,297 @@ +package requirementsourcemodel + +// Field represents one lexical owner of an effective metadata value. Present +// distinguishes an omitted field from a present zero or nullable value. +type Field[T any] struct { + Present bool + Value T +} + +func Own[T any](value T) Field[T] { + return Field[T]{Present: true, Value: value} +} + +type ClaimLevel string + +const ( + ClaimAdvisory ClaimLevel = "advisory" + ClaimBlocking ClaimLevel = "blocking" + ClaimDeferred ClaimLevel = "deferred" +) + +type RiskClass string + +const ( + RiskCritical RiskClass = "critical" + RiskHigh RiskClass = "high" + RiskLow RiskClass = "low" + RiskMedium RiskClass = "medium" +) + +type LifecycleState string + +const ( + LifecycleActive LifecycleState = "active" + LifecycleDeprecated LifecycleState = "deprecated" + LifecycleRemoved LifecycleState = "removed" + LifecycleSuperseded LifecycleState = "superseded" +) + +type TermKind string + +const ( + TermAction TermKind = "action" + TermObservable TermKind = "observable" + TermState TermKind = "state" + TermSubject TermKind = "subject" + TermValue TermKind = "value" +) + +type SourceKind string + +const ( + SourceClarification SourceKind = "clarification" + SourceCodeSnapshot SourceKind = "code_snapshot" + SourceDesign SourceKind = "design" + SourceOwnerDecision SourceKind = "owner_decision" + SourcePlan SourceKind = "plan" +) + +type ObjectFormat string + +const ( + ObjectSHA1 ObjectFormat = "sha1" + ObjectSHA256 ObjectFormat = "sha256" +) + +type ScenarioValue string + +type MetadataFieldID string + +type MetadataOwnerKind string + +const ( + MetadataOwnerMember MetadataOwnerKind = "member" + MetadataOwnerProfile MetadataOwnerKind = "profile" +) + +type EntityKind string + +const ( + EntityDerivation EntityKind = "derivation" + EntityGroup EntityKind = "group" + EntityNonClaim EntityKind = "nonclaim" + EntityProfile EntityKind = "profile" + EntityRequirement EntityKind = "requirement" + EntityScenario EntityKind = "scenario" + EntitySource EntityKind = "source" + EntityTerm EntityKind = "term" +) + +type ReferenceKind string + +const ( + ReferenceDerivationNonClaim ReferenceKind = "derivation_nonclaim" + ReferenceDerivationRequirement ReferenceKind = "derivation_requirement" + ReferenceGroupMember ReferenceKind = "group_member" + ReferenceGroupProfile ReferenceKind = "group_profile" + ReferenceLifecycleReplacement ReferenceKind = "lifecycle_replacement" + ReferenceRequirementNonClaim ReferenceKind = "requirement_nonclaim" + ReferenceScenarioNonClaim ReferenceKind = "scenario_nonclaim" + ReferenceScenarioRequirement ReferenceKind = "scenario_requirement" + ReferenceScenarioVocabulary ReferenceKind = "scenario_vocabulary" + ReferenceSourceNonClaim ReferenceKind = "source_nonclaim" +) + +type Draft struct { + SourceID string + SpecPackagePath string + SourceNonClaimRefs []string + NonClaimDefinitions []NonClaimDefinition + Vocabulary []VocabularyTerm + Derivations []Derivation + Profiles []Profile + Groups []Group + Scenarios []Scenario +} + +type NonClaimDefinition struct { + NonClaimID string + Statement string +} + +type VocabularyTerm struct { + TermID string + Kind TermKind + Label string + Definition string +} + +type Derivation struct { + DerivationID string + SourceKind SourceKind + SourceRef GitBlobRef + Selector ByteRange + RequirementIDs []string + NonClaimRefs []string +} + +type GitBlobRef struct { + ObjectFormat ObjectFormat + CommitOID string + Path string + SHA256 string +} + +type ByteRange struct { + Start int64 + End int64 +} + +type Profile struct { + ProfileID string + Fields MetadataFields +} + +type Group struct { + GroupID string + ProfileID string + StatementStem string + SharedPremises []string + Members []Member +} + +type Member struct { + RequirementID string + StatementCompletion string + Fields MetadataFields +} + +type MetadataFields struct { + OwnerID Field[string] + ClaimLevel Field[ClaimLevel] + RiskClass Field[RiskClass] + NonClaimRefs Field[[]string] + Lifecycle Field[Lifecycle] + Deferral Field[*Deferral] + UpdatePolicy Field[UpdatePolicy] +} + +type Lifecycle struct { + State LifecycleState + ReplacementRequirementIDs []string + EvidenceRefs []string +} + +type Deferral struct { + OwnerID string + RiskAcceptedBy string + ReviewCondition string + ExpiryRef string + MergePolicy string + EvidenceRefs []string +} + +type UpdatePolicy struct { + ReviewOwnerID string + RequiresImpactDeclaration bool + RequiresProofBindingReview bool +} + +type Scenario struct { + ScenarioID string + RequirementIDs []string + Parameters []string + Preconditions []string + ActionSequence []string + ExpectedObservations []string + ForbiddenObservations []string + Examples []Example + VocabularyRefs []string + NonClaimRefs []string +} + +type Example struct { + ExampleID string + Values map[string]ScenarioValue +} + +// Model owns the admitted normalized value. Its fields remain private so a +// caller cannot mutate the owner snapshot through a returned slice or map. +type Model struct { + atomic AtomicProjection + layout LayoutProjection + references ReferenceProjection +} + +type AtomicProjection struct { + SourceID string + SpecPackagePath string + SourceNonClaimRefs []string + NonClaimDefinitions []NonClaimDefinition + Vocabulary []VocabularyTerm + Requirements []AtomicRequirement + Scenarios []Scenario +} + +type AtomicRequirement struct { + RequirementID string + Invariant string + SharedPremises []string + OwnerID string + ClaimLevel ClaimLevel + RiskClass RiskClass + NonClaimRefs []string + Lifecycle Lifecycle + Deferral *Deferral + UpdatePolicy UpdatePolicy +} + +type LayoutProjection struct { + SourceID string + Profiles []Profile + Groups []Group + Origins []Origin +} + +type Origin struct { + RequirementID string + GroupID string + ProfileID string + FieldOwners []FieldOwner +} + +type FieldOwner struct { + FieldID MetadataFieldID + OwnerKind MetadataOwnerKind + OwnerID string +} + +type ReferenceProjection struct { + SourceID string + Derivations []Derivation + Edges []ReferenceEdge +} + +type ReferenceEdge struct { + Kind ReferenceKind + From ReferenceEndpoint + To ReferenceEndpoint +} + +type ReferenceEndpoint struct { + Kind EntityKind + ID string +} + +func (model Model) Atomic() AtomicProjection { + return cloneAtomicProjection(model.atomic) +} + +func (model Model) Layout() LayoutProjection { + return cloneLayoutProjection(model.layout) +} + +func (model Model) References() ReferenceProjection { + return cloneReferenceProjection(model.references) +} diff --git a/internal/kernel/requirementsourcemodel/validation.go b/internal/kernel/requirementsourcemodel/validation.go new file mode 100644 index 0000000..18ab90f --- /dev/null +++ b/internal/kernel/requirementsourcemodel/validation.go @@ -0,0 +1,187 @@ +package requirementsourcemodel + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +var placeholderPattern = regexp.MustCompile(`(?i)\b(?:fixme|todo|tbd)\b`) +var parameterPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) +var parameterReferencePattern = regexp.MustCompile(`\$\{([a-z][a-z0-9_]*)\}`) + +var claimLevelVariants = []ClaimLevel{ClaimAdvisory, ClaimBlocking, ClaimDeferred} +var riskClassVariants = []RiskClass{RiskCritical, RiskHigh, RiskLow, RiskMedium} +var lifecycleStateVariants = []LifecycleState{LifecycleActive, LifecycleDeprecated, LifecycleRemoved, LifecycleSuperseded} +var termKindVariants = []TermKind{TermAction, TermObservable, TermState, TermSubject, TermValue} +var sourceKindVariants = []SourceKind{SourceClarification, SourceCodeSnapshot, SourceDesign, SourceOwnerDecision, SourcePlan} +var objectFormatVariants = []ObjectFormat{ObjectSHA1, ObjectSHA256} + +type ValidationError struct { + Code string + Path string +} + +func (err *ValidationError) Error() string { + return fmt.Sprintf("%s: %s", err.Code, err.Path) +} + +func ErrorCode(err error) string { + if typed, ok := err.(*ValidationError); ok { + return typed.Code + } + return "" +} + +func invalid(code string, path string) error { + return &ValidationError{Code: code, Path: path} +} + +func canonicalID(value string, prefix string, path string) (string, error) { + admitted, err := admit.RuleID(value, path) + if err != nil || !strings.HasPrefix(admitted, prefix) || len(admitted) == len(prefix) { + return "", invalid("invalid_id", path) + } + return admitted, nil +} + +func canonicalExternalID(value string, path string) (string, error) { + admitted, err := admit.RuleID(value, path) + if err != nil { + return "", invalid("invalid_id", path) + } + return admitted, nil +} + +func canonicalText(value string, path string, allowEmpty bool, rejectPlaceholders bool) (string, error) { + if allowEmpty && value == "" { + return "", nil + } + admitted, err := admit.NonEmptyText(value, path) + if err != nil || admitted != value { + return "", invalid("invalid_text", path) + } + for _, character := range value { + if character < 0x20 || character == 0x7f { + return "", invalid("invalid_text", path) + } + } + if rejectPlaceholders && placeholderPattern.MatchString(value) { + return "", invalid("placeholder_text", path) + } + return value, nil +} + +func canonicalPath(value string, path string) (string, error) { + admitted, err := admit.SafeRepoRelativePath(value, path) + if err != nil || admitted != value { + return "", invalid("invalid_path", path) + } + return admitted, nil +} + +func normalizeIDs(values []string, prefix string, path string, allowEmpty bool) ([]string, error) { + if !allowEmpty && len(values) == 0 { + return nil, invalid("empty_collection", path) + } + result := make([]string, len(values)) + for index, value := range values { + admitted, err := canonicalID(value, prefix, fmt.Sprintf("%s[%d]", path, index)) + if err != nil { + return nil, err + } + result[index] = admitted + } + return sortUnique(result, path) +} + +func normalizeTexts(values []string, path string, allowEmpty bool, rejectPlaceholders bool) ([]string, error) { + if !allowEmpty && len(values) == 0 { + return nil, invalid("empty_collection", path) + } + result := make([]string, len(values)) + for index, value := range values { + admitted, err := canonicalText(value, fmt.Sprintf("%s[%d]", path, index), false, rejectPlaceholders) + if err != nil { + return nil, err + } + result[index] = admitted + } + return sortUnique(result, path) +} + +func normalizeOrderedTexts(values []string, path string, allowEmpty bool) ([]string, error) { + if !allowEmpty && len(values) == 0 { + return nil, invalid("empty_collection", path) + } + result := make([]string, len(values)) + for index, value := range values { + admitted, err := canonicalText(value, fmt.Sprintf("%s[%d]", path, index), false, true) + if err != nil { + return nil, err + } + result[index] = admitted + } + return result, nil +} + +func normalizePaths(values []string, path string, allowEmpty bool) ([]string, error) { + if !allowEmpty && len(values) == 0 { + return nil, invalid("empty_collection", path) + } + result := make([]string, len(values)) + for index, value := range values { + admitted, err := canonicalPath(value, fmt.Sprintf("%s[%d]", path, index)) + if err != nil { + return nil, err + } + result[index] = admitted + } + return sortUnique(result, path) +} + +func sortUnique(values []string, path string) ([]string, error) { + sort.Strings(values) + for index := 1; index < len(values); index++ { + if values[index-1] == values[index] { + return nil, invalid("duplicate_value", path) + } + } + return values, nil +} + +func validClaimLevel(value ClaimLevel, path string) error { + return validClosedVariant(value, claimLevelVariants, "invalid_claim_level", path) +} + +func validRiskClass(value RiskClass, path string) error { + return validClosedVariant(value, riskClassVariants, "invalid_risk_class", path) +} + +func validLifecycleState(value LifecycleState, path string) error { + return validClosedVariant(value, lifecycleStateVariants, "invalid_lifecycle_state", path) +} + +func validTermKind(value TermKind, path string) error { + return validClosedVariant(value, termKindVariants, "invalid_term_kind", path) +} + +func validSourceKind(value SourceKind, path string) error { + return validClosedVariant(value, sourceKindVariants, "invalid_source_kind", path) +} + +func validObjectFormat(value ObjectFormat, path string) error { + return validClosedVariant(value, objectFormatVariants, "invalid_object_format", path) +} + +func validClosedVariant[T ~string](value T, variants []T, code string, path string) error { + for _, variant := range variants { + if value == variant { + return nil + } + } + return invalid(code, path) +} diff --git a/internal/kernel/requirementsourcemodel/variant_inventory_test.go b/internal/kernel/requirementsourcemodel/variant_inventory_test.go new file mode 100644 index 0000000..ad7f378 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/variant_inventory_test.go @@ -0,0 +1,91 @@ +package requirementsourcemodel + +import ( + "go/constant" + "go/types" + "reflect" + "sort" + "testing" + + "golang.org/x/tools/go/packages" +) + +func TestIndependentManifestMatchesExactProductionEnumInventory(t *testing.T) { + manifest := readStrictJSON[completenessManifest](t, "testdata/field-projection-manifest.v1.json") + manifestValues := map[string][]string{} + for _, variant := range manifest.Variants { + manifestValues[variant.VariantID] = variant.Values + } + expected := map[string]string{ + "claimLevel": "ClaimLevel", + "riskClass": "RiskClass", + "lifecycleState": "LifecycleState", + "termKind": "TermKind", + "sourceKind": "SourceKind", + "objectFormat": "ObjectFormat", + "metadataOwner": "MetadataOwnerKind", + "entityKind": "EntityKind", + "referenceKind": "ReferenceKind", + } + accepted := map[string][]string{ + "claimLevel": variantStrings(claimLevelVariants), + "riskClass": variantStrings(riskClassVariants), + "lifecycleState": variantStrings(lifecycleStateVariants), + "termKind": variantStrings(termKindVariants), + "sourceKind": variantStrings(sourceKindVariants), + "objectFormat": variantStrings(objectFormatVariants), + } + for variantID, values := range accepted { + sort.Strings(values) + if !reflect.DeepEqual(values, manifestValues[variantID]) { + t.Fatalf("validator variants %s = %v, manifest = %v", variantID, values, manifestValues[variantID]) + } + } + production := productionEnumValues(t) + for variantID, typeName := range expected { + actual := append([]string(nil), production[typeName]...) + sort.Strings(actual) + if !reflect.DeepEqual(actual, manifestValues[variantID]) { + t.Fatalf("production enum %s = %v, manifest %s = %v", typeName, actual, variantID, manifestValues[variantID]) + } + delete(production, typeName) + } + if len(production) != 0 { + t.Fatalf("unclassified production enum types: %v", production) + } +} + +func variantStrings[T ~string](values []T) []string { + result := make([]string, len(values)) + for index, value := range values { + result[index] = string(value) + } + return result +} + +func productionEnumValues(t *testing.T) map[string][]string { + t.Helper() + loaded, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedSyntax | packages.NeedFiles, + }, ".") + if err != nil { + t.Fatal(err) + } + if packages.PrintErrors(loaded) != 0 || len(loaded) != 1 { + t.Fatalf("loaded production packages = %d", len(loaded)) + } + checked := loaded[0].Types + result := map[string][]string{} + for _, name := range checked.Scope().Names() { + value, ok := checked.Scope().Lookup(name).(*types.Const) + if !ok { + continue + } + named, ok := value.Type().(*types.Named) + if !ok || named.Obj().Pkg() != checked || named.Underlying().String() != "string" || value.Val().Kind() != constant.String { + continue + } + result[named.Obj().Name()] = append(result[named.Obj().Name()], constant.StringVal(value.Val())) + } + return result +} diff --git a/internal/kernel/requirementsourcemodel/variants_test.go b/internal/kernel/requirementsourcemodel/variants_test.go new file mode 100644 index 0000000..8cf2d99 --- /dev/null +++ b/internal/kernel/requirementsourcemodel/variants_test.go @@ -0,0 +1,488 @@ +package requirementsourcemodel + +import ( + "testing" +) + +func TestNormalizeAdmitsEveryDeclaredEnumVariantThroughWholeModel(t *testing.T) { + tests := []struct { + name string + edit func(*Draft) + }{ + {name: "claim advisory", edit: func(draft *Draft) { setSingletonClaim(draft, ClaimAdvisory) }}, + {name: "claim blocking", edit: func(draft *Draft) { setSingletonClaim(draft, ClaimBlocking) }}, + {name: "claim deferred", edit: func(draft *Draft) { setSingletonClaim(draft, ClaimDeferred) }}, + {name: "risk critical", edit: func(draft *Draft) { draft.Groups[1].Members[0].Fields.RiskClass.Value = RiskCritical }}, + {name: "risk high", edit: func(draft *Draft) { draft.Groups[1].Members[0].Fields.RiskClass.Value = RiskHigh }}, + {name: "risk low", edit: func(draft *Draft) { draft.Groups[1].Members[0].Fields.RiskClass.Value = RiskLow }}, + {name: "risk medium", edit: func(draft *Draft) { draft.Groups[1].Members[0].Fields.RiskClass.Value = RiskMedium }}, + {name: "lifecycle active", edit: func(draft *Draft) { setSingletonLifecycle(draft, LifecycleActive) }}, + {name: "lifecycle deprecated", edit: func(draft *Draft) { setSingletonLifecycle(draft, LifecycleDeprecated) }}, + {name: "lifecycle removed", edit: func(draft *Draft) { setSingletonLifecycle(draft, LifecycleRemoved) }}, + {name: "lifecycle superseded", edit: func(draft *Draft) { setSingletonLifecycle(draft, LifecycleSuperseded) }}, + {name: "term action", edit: func(draft *Draft) { draft.Vocabulary[0].Kind = TermAction }}, + {name: "term observable", edit: func(draft *Draft) { draft.Vocabulary[0].Kind = TermObservable }}, + {name: "term state", edit: func(draft *Draft) { draft.Vocabulary[0].Kind = TermState }}, + {name: "term subject", edit: func(draft *Draft) { draft.Vocabulary[0].Kind = TermSubject }}, + {name: "term value", edit: func(draft *Draft) { draft.Vocabulary[0].Kind = TermValue }}, + {name: "source clarification", edit: func(draft *Draft) { draft.Derivations[0].SourceKind = SourceClarification }}, + {name: "source code snapshot", edit: func(draft *Draft) { draft.Derivations[0].SourceKind = SourceCodeSnapshot }}, + {name: "source design", edit: func(draft *Draft) { draft.Derivations[0].SourceKind = SourceDesign }}, + {name: "source owner decision", edit: func(draft *Draft) { draft.Derivations[0].SourceKind = SourceOwnerDecision }}, + {name: "source plan", edit: func(draft *Draft) { draft.Derivations[0].SourceKind = SourcePlan }}, + {name: "object sha1", edit: func(draft *Draft) { setObjectFormat(draft, ObjectSHA1) }}, + {name: "object sha256", edit: func(draft *Draft) { setObjectFormat(draft, ObjectSHA256) }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + draft := validDraft() + test.edit(&draft) + if _, err := Normalize(draft); err != nil { + t.Fatalf("whole-model normalization rejected declared variant: %v", err) + } + }) + } +} + +func TestNormalizeProjectsEveryManifestVariantThroughWholeModel(t *testing.T) { + manifest := readStrictJSON[completenessManifest](t, "testdata/field-projection-manifest.v1.json") + for _, variant := range manifest.Variants { + for _, value := range variant.Values { + t.Run(variant.VariantID+"/"+value, func(t *testing.T) { + draft := validDraft() + if !applyVariantInput(&draft, variant.VariantID, value) { + t.Fatalf("variant %s=%s has no independent whole-model fixture", variant.VariantID, value) + } + model, err := Normalize(draft) + if err != nil { + t.Fatalf("Normalize() rejected %s=%s: %v", variant.VariantID, value, err) + } + for _, projection := range variant.RequiredProjections { + if !variantProjected(model, variant.VariantID, value, projection) { + t.Fatalf("%s=%s is absent from %s projection", variant.VariantID, value, projection) + } + } + }) + } + } +} + +func TestNormalizeAdmitsAndProjectsMetadataEnumVariantsFromEachOwner(t *testing.T) { + manifest := readStrictJSON[completenessManifest](t, "testdata/field-projection-manifest.v1.json") + for _, variant := range manifest.Variants { + if variant.VariantID != "claimLevel" && variant.VariantID != "riskClass" && variant.VariantID != "lifecycleState" { + continue + } + for _, ownerKind := range []MetadataOwnerKind{MetadataOwnerMember, MetadataOwnerProfile} { + for _, value := range variant.Values { + t.Run(variant.VariantID+"/"+string(ownerKind)+"/"+value, func(t *testing.T) { + draft := validDraft() + requirementID := applyMetadataVariantForOwner(&draft, variant.VariantID, value, ownerKind) + model, err := Normalize(draft) + if err != nil { + t.Fatalf("Normalize() rejected variant: %v", err) + } + assertMetadataVariantForOwner(t, model, variant.VariantID, value, ownerKind, requirementID) + }) + } + } + } +} + +func applyMetadataVariantForOwner(draft *Draft, variantID string, value string, ownerKind MetadataOwnerKind) string { + if ownerKind == MetadataOwnerMember { + switch variantID { + case "claimLevel": + setSingletonClaim(draft, ClaimLevel(value)) + case "riskClass": + draft.Groups[1].Members[0].Fields.RiskClass.Value = RiskClass(value) + case "lifecycleState": + setSingletonLifecycle(draft, LifecycleState(value)) + } + return "REQ-MODEL-003" + } + + profile := &draft.Profiles[0].Fields + switch variantID { + case "claimLevel": + profile.ClaimLevel.Value = ClaimLevel(value) + if ClaimLevel(value) == ClaimDeferred { + profile.Deferral = Own(cloneDeferral(draft.Groups[1].Members[0].Fields.Deferral.Value)) + for index := range draft.Groups[0].Members { + draft.Groups[0].Members[index].Fields.Deferral = Field[*Deferral]{} + } + } + case "riskClass": + profile.RiskClass.Value = RiskClass(value) + case "lifecycleState": + state := LifecycleState(value) + lifecycle := Lifecycle{State: state} + if state != LifecycleActive { + lifecycle.EvidenceRefs = []string{"docs/evidence/lifecycle.md"} + } + if state == LifecycleSuperseded { + lifecycle.ReplacementRequirementIDs = []string{"REQ-MODEL-003"} + } + if state != LifecycleActive { + profile.ClaimLevel.Value = ClaimAdvisory + draft.Groups[2].Members[0].Fields.Lifecycle.Value.ReplacementRequirementIDs = []string{"REQ-MODEL-003"} + } + profile.Lifecycle = Own(lifecycle) + for index := range draft.Groups[0].Members { + draft.Groups[0].Members[index].Fields.Lifecycle = Field[Lifecycle]{} + } + } + return "REQ-MODEL-001" +} + +func assertMetadataVariantForOwner(t *testing.T, model Model, variantID string, value string, ownerKind MetadataOwnerKind, requirementID string) { + t.Helper() + foundAtomic := false + for _, requirement := range model.Atomic().Requirements { + if requirement.RequirementID != requirementID { + continue + } + foundAtomic = metadataAtomicVariantValue(requirement, variantID) == value + } + if !foundAtomic { + t.Fatalf("%s=%s is absent from atomic requirement %s", variantID, value, requirementID) + } + + layout := model.Layout() + for _, origin := range layout.Origins { + if origin.RequirementID != requirementID { + continue + } + fieldID := MetadataFieldID(metadataVariantFieldID(variantID)) + for _, owner := range origin.FieldOwners { + if owner.FieldID == fieldID && owner.OwnerKind == ownerKind && metadataLayoutVariantValue(layout, origin, variantID, ownerKind) == value { + return + } + } + } + t.Fatalf("%s=%s is absent from %s-owned layout", variantID, value, ownerKind) +} + +func metadataAtomicVariantValue(requirement AtomicRequirement, variantID string) string { + switch variantID { + case "claimLevel": + return string(requirement.ClaimLevel) + case "riskClass": + return string(requirement.RiskClass) + case "lifecycleState": + return string(requirement.Lifecycle.State) + default: + return "" + } +} + +func metadataLayoutVariantValue(layout LayoutProjection, origin Origin, variantID string, ownerKind MetadataOwnerKind) string { + if ownerKind == MetadataOwnerProfile { + for _, profile := range layout.Profiles { + if profile.ProfileID == origin.ProfileID { + return metadataFieldsVariantValue(profile.Fields, variantID) + } + } + return "" + } + fields, exists := memberFieldsByRequirement(layout, origin.RequirementID) + if !exists { + return "" + } + return metadataFieldsVariantValue(fields, variantID) +} + +func metadataFieldsVariantValue(fields MetadataFields, variantID string) string { + switch variantID { + case "claimLevel": + if fields.ClaimLevel.Present { + return string(fields.ClaimLevel.Value) + } + case "riskClass": + if fields.RiskClass.Present { + return string(fields.RiskClass.Value) + } + case "lifecycleState": + if fields.Lifecycle.Present { + return string(fields.Lifecycle.Value.State) + } + } + return "" +} + +func metadataVariantFieldID(variantID string) string { + switch variantID { + case "claimLevel": + return "claimLevel" + case "riskClass": + return "riskClass" + case "lifecycleState": + return "lifecycle" + default: + return "" + } +} + +func applyVariantInput(draft *Draft, variantID string, value string) bool { + switch variantID { + case "claimLevel": + setSingletonClaim(draft, ClaimLevel(value)) + case "riskClass": + draft.Groups[1].Members[0].Fields.RiskClass.Value = RiskClass(value) + case "lifecycleState": + setSingletonLifecycle(draft, LifecycleState(value)) + case "termKind": + draft.Vocabulary[0].Kind = TermKind(value) + case "sourceKind": + draft.Derivations[0].SourceKind = SourceKind(value) + case "objectFormat": + setObjectFormat(draft, ObjectFormat(value)) + case "entityKind", "referenceKind", "metadataOwner", "deferral", "profileRef", "scenarioValue": + // The baseline contains every derived or presence variant. + default: + return false + } + return true +} + +func variantProjected(model Model, variantID string, value string, projection string) bool { + switch variantID { + case "claimLevel", "riskClass", "lifecycleState": + return metadataEnumProjected(model, variantID, value, projection) + case "termKind": + return projection == "atomic" && len(model.Atomic().Vocabulary) == 1 && string(model.Atomic().Vocabulary[0].Kind) == value + case "sourceKind": + return projection == "references" && len(model.References().Derivations) == 1 && string(model.References().Derivations[0].SourceKind) == value + case "objectFormat": + return projection == "references" && len(model.References().Derivations) == 1 && string(model.References().Derivations[0].SourceRef.ObjectFormat) == value + case "entityKind": + if projection != "references" { + return false + } + for _, edge := range model.References().Edges { + if string(edge.From.Kind) == value || string(edge.To.Kind) == value { + return true + } + } + case "referenceKind": + if projection != "references" { + return false + } + for _, edge := range model.References().Edges { + if string(edge.Kind) == value { + return true + } + } + case "metadataOwner": + if projection != "layout" { + return false + } + for _, origin := range model.Layout().Origins { + for _, owner := range origin.FieldOwners { + if string(owner.OwnerKind) == value { + return true + } + } + } + case "deferral": + return deferralVariantProjected(model, value, projection) + case "profileRef": + return profileRefVariantProjected(model, value, projection) + case "scenarioValue": + atomic := model.Atomic() + return projection == "atomic" && value == "string" && len(atomic.Scenarios) == 1 && string(atomic.Scenarios[0].Examples[0].Values["surface"]) == "primary" + } + return false +} + +func metadataEnumProjected(model Model, variantID string, value string, projection string) bool { + if projection == "atomic" { + for _, requirement := range model.Atomic().Requirements { + if requirement.RequirementID != "REQ-MODEL-003" { + continue + } + switch variantID { + case "claimLevel": + return string(requirement.ClaimLevel) == value + case "riskClass": + return string(requirement.RiskClass) == value + case "lifecycleState": + return string(requirement.Lifecycle.State) == value + } + } + } + if projection == "layout" { + fields, exists := memberFieldsByRequirement(model.Layout(), "REQ-MODEL-003") + if !exists { + return false + } + switch variantID { + case "claimLevel": + return fields.ClaimLevel.Present && string(fields.ClaimLevel.Value) == value + case "riskClass": + return fields.RiskClass.Present && string(fields.RiskClass.Value) == value + case "lifecycleState": + return fields.Lifecycle.Present && string(fields.Lifecycle.Value.State) == value + } + } + return false +} + +func deferralVariantProjected(model Model, value string, projection string) bool { + requirementID := "REQ-MODEL-003" + wantRecord := value == "record" + if value == "explicit_null" { + requirementID = "REQ-MODEL-001" + } else if !wantRecord { + return false + } + if projection == "atomic" { + for _, requirement := range model.Atomic().Requirements { + if requirement.RequirementID == requirementID { + return (requirement.Deferral != nil) == wantRecord + } + } + } + if projection == "layout" { + fields, exists := memberFieldsByRequirement(model.Layout(), requirementID) + return exists && fields.Deferral.Present && (fields.Deferral.Value != nil) == wantRecord + } + return false +} + +func profileRefVariantProjected(model Model, value string, projection string) bool { + groupID := "RGRP-MODEL-REQUESTS" + wantPresent := value == "present" + if value == "absent" { + groupID = "RGRP-MODEL-DEFERRED" + } else if !wantPresent { + return false + } + if projection == "layout" { + for _, group := range model.Layout().Groups { + if group.GroupID == groupID { + return (group.ProfileID != "") == wantPresent + } + } + } + if projection == "references" { + found := false + for _, edge := range model.References().Edges { + if edge.Kind == ReferenceGroupProfile && edge.From.ID == groupID { + found = true + } + } + return found == wantPresent + } + return false +} + +func memberFieldsByRequirement(layout LayoutProjection, requirementID string) (MetadataFields, bool) { + for _, group := range layout.Groups { + for _, member := range group.Members { + if member.RequirementID == requirementID { + return member.Fields, true + } + } + } + return MetadataFields{}, false +} + +func TestNormalizeRejectsUnknownEnumVariantsThroughWholeModel(t *testing.T) { + tests := []struct { + name string + code string + edit func(*Draft) + }{ + {name: "claim", code: "invalid_claim_level", edit: func(draft *Draft) { draft.Groups[1].Members[0].Fields.ClaimLevel.Value = "unknown" }}, + {name: "risk", code: "invalid_risk_class", edit: func(draft *Draft) { draft.Groups[1].Members[0].Fields.RiskClass.Value = "unknown" }}, + {name: "lifecycle", code: "invalid_lifecycle_state", edit: func(draft *Draft) { draft.Groups[1].Members[0].Fields.Lifecycle.Value.State = "unknown" }}, + {name: "profile claim", code: "invalid_claim_level", edit: func(draft *Draft) { draft.Profiles[0].Fields.ClaimLevel.Value = "unknown" }}, + {name: "profile risk", code: "invalid_risk_class", edit: func(draft *Draft) { draft.Profiles[0].Fields.RiskClass.Value = "unknown" }}, + {name: "profile lifecycle", code: "invalid_lifecycle_state", edit: func(draft *Draft) { + draft.Profiles[0].Fields.Lifecycle = Own(Lifecycle{State: "unknown"}) + for index := range draft.Groups[0].Members { + draft.Groups[0].Members[index].Fields.Lifecycle = Field[Lifecycle]{} + } + }}, + {name: "term", code: "invalid_term_kind", edit: func(draft *Draft) { draft.Vocabulary[0].Kind = "unknown" }}, + {name: "source", code: "invalid_source_kind", edit: func(draft *Draft) { draft.Derivations[0].SourceKind = "unknown" }}, + {name: "object", code: "invalid_object_format", edit: func(draft *Draft) { draft.Derivations[0].SourceRef.ObjectFormat = "unknown" }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + draft := validDraft() + test.edit(&draft) + _, err := Normalize(draft) + if ErrorCode(err) != test.code { + t.Fatalf("ErrorCode() = %q, error = %v, want %q", ErrorCode(err), err, test.code) + } + }) + } +} + +func TestNormalizePreservesEveryDeclaredPresenceVariant(t *testing.T) { + model, err := Normalize(validDraft()) + if err != nil { + t.Fatal(err) + } + owners := map[MetadataOwnerKind]bool{} + profileRefs := map[bool]bool{} + for _, origin := range model.Layout().Origins { + profileRefs[origin.ProfileID != ""] = true + for _, owner := range origin.FieldOwners { + owners[owner.OwnerKind] = true + } + } + if !owners[MetadataOwnerMember] || !owners[MetadataOwnerProfile] { + t.Fatalf("metadata owner variants = %v", owners) + } + if !profileRefs[false] || !profileRefs[true] { + t.Fatalf("profile reference variants = %v", profileRefs) + } + deferrals := map[string]bool{} + for _, requirement := range model.Atomic().Requirements { + if requirement.Deferral == nil { + deferrals["explicit_null"] = true + } else { + deferrals["record"] = true + } + } + if !deferrals["explicit_null"] || !deferrals["record"] { + t.Fatalf("deferral variants = %v", deferrals) + } + if _, ok := model.Atomic().Scenarios[0].Examples[0].Values["surface"]; !ok { + t.Fatal("string scenario value variant is absent") + } +} + +func setSingletonClaim(draft *Draft, claim ClaimLevel) { + fields := &draft.Groups[1].Members[0].Fields + fields.ClaimLevel.Value = claim + if claim == ClaimDeferred { + return + } + fields.Deferral.Value = nil +} + +func setSingletonLifecycle(draft *Draft, state LifecycleState) { + lifecycle := &draft.Groups[1].Members[0].Fields.Lifecycle.Value + lifecycle.State = state + lifecycle.ReplacementRequirementIDs = nil + lifecycle.EvidenceRefs = nil + if state != LifecycleActive { + lifecycle.EvidenceRefs = []string{"docs/evidence/lifecycle.md"} + } + if state == LifecycleSuperseded { + lifecycle.ReplacementRequirementIDs = []string{"REQ-MODEL-001"} + } +} + +func setObjectFormat(draft *Draft, format ObjectFormat) { + draft.Derivations[0].SourceRef.ObjectFormat = format + if format == ObjectSHA1 { + draft.Derivations[0].SourceRef.CommitOID = "0123456789abcdef0123456789abcdef01234567" + return + } + draft.Derivations[0].SourceRef.CommitOID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +} diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index 9603caf..21cfb58 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -671,6 +671,14 @@ "claimLevel": "blocking", "proofState": "witness_backed", "nonClaims": ["Expected-digest coverage does not authenticate a producer, baseline, checkout, or freshness; this requirement also does not infer code topology, line or branch coverage, native execution, merge approval, release approval, rollout approval, or production readiness."] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-024", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "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."] } ], "bindings": [ @@ -3792,6 +3800,297 @@ "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-024", + "scenarioId": "proofkit.spec-proof-core.requirement-source-v2-normalization-closure", + "witnessId": "proofkit.requirement-source-v2.normalization-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcemodel/model_test.go", + "witnessSelectors": [ + { + "selector": "TestNormalizeBuildsDeterministicSeparatedProjections", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeBuildsDeterministicSeparatedProjections$'" + }, + { + "selector": "TestNormalizeRejectsInvalidReferenceAndMetadataPartitions", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeRejectsInvalidReferenceAndMetadataPartitions$'" + }, + { + "selector": "TestNormalizeBuildsEveryTypedReferenceRole", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeBuildsEveryTypedReferenceRole$'" + }, + { + "selector": "TestModelAccessorsDoNotExposeMutableOwnerState", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestModelAccessorsDoNotExposeMutableOwnerState$'" + }, + { + "selector": "TestNormalizeRejectsBudgetBeforeMemberSemantics", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeRejectsBudgetBeforeMemberSemantics$'" + }, + { + "selector": "TestNormalizeEnforcesExactInputTextBudgetBeforeElementSemantics", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeEnforcesExactInputTextBudgetBeforeElementSemantics$'" + }, + { + "selector": "TestNormalizeRejectsExpandedProjectionBudgetsBeforeMemberSemantics", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeRejectsExpandedProjectionBudgetsBeforeMemberSemantics$'" + }, + { + "selector": "TestNormalizeRejectsContradictoryScenarioObservations", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeRejectsContradictoryScenarioObservations$'" + }, + { + "selector": "TestNormalizeReportsUnreferencedDefinitionsDeterministically", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeReportsUnreferencedDefinitionsDeterministically$'" + }, + { + "selector": "TestNormalizeDiagnosticsDoNotEchoCallerText", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeDiagnosticsDoNotEchoCallerText$'" + }, + { + "selector": "TestNormalizeRejectsNestedCollectionBudgetBeforeElementSemantics", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeRejectsNestedCollectionBudgetBeforeElementSemantics$'" + }, + { + "selector": "TestNormalizeSeparatesAuthoringLayoutFromAtomicSemantics", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeSeparatesAuthoringLayoutFromAtomicSemantics$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-024", + "scenarioId": "proofkit.spec-proof-core.requirement-source-v2-variant-closure", + "witnessId": "proofkit.requirement-source-v2.variant-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcemodel/variants_test.go", + "witnessSelectors": [ + { + "selector": "TestNormalizeAdmitsEveryDeclaredEnumVariantThroughWholeModel", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeAdmitsEveryDeclaredEnumVariantThroughWholeModel$'" + }, + { + "selector": "TestNormalizeProjectsEveryManifestVariantThroughWholeModel", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeProjectsEveryManifestVariantThroughWholeModel$'" + }, + { + "selector": "TestNormalizeAdmitsAndProjectsMetadataEnumVariantsFromEachOwner", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeAdmitsAndProjectsMetadataEnumVariantsFromEachOwner$'" + }, + { + "selector": "TestNormalizeRejectsUnknownEnumVariantsThroughWholeModel", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeRejectsUnknownEnumVariantsThroughWholeModel$'" + }, + { + "selector": "TestNormalizePreservesEveryDeclaredPresenceVariant", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizePreservesEveryDeclaredPresenceVariant$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-024", + "scenarioId": "proofkit.spec-proof-core.requirement-source-v2-instantiated-scenario-closure", + "witnessId": "proofkit.requirement-source-v2.instantiated-scenario-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcemodel/scenario_evaluation_test.go", + "witnessSelectors": [ + { + "selector": "TestNormalizeRejectsContradictoryInstantiatedObservations", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeRejectsContradictoryInstantiatedObservations$'" + }, + { + "selector": "TestScenarioEvaluationBudgetMatchesIndependentInstantiation", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestScenarioEvaluationBudgetMatchesIndependentInstantiation$'" + }, + { + "selector": "TestScenarioParameterValuesAreSubstitutedOnce", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestScenarioParameterValuesAreSubstitutedOnce$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-024", + "scenarioId": "proofkit.spec-proof-core.requirement-source-v2-identity-aware-mutation-closure", + "witnessId": "proofkit.requirement-source-v2.identity-aware-mutation-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcemodel/draft_observation_test.go", + "witnessSelectors": [ + { + "selector": "TestChangedDraftFieldIDsIsIdentityAware", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestChangedDraftFieldIDsIsIdentityAware$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-024", + "scenarioId": "proofkit.spec-proof-core.requirement-source-v2-metadata-owner-closure", + "witnessId": "proofkit.requirement-source-v2.metadata-owner-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcemodel/metadata_ownership_test.go", + "witnessSelectors": [ + { + "selector": "TestNormalizePreservesExactMetadataOwnershipMatrix", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizePreservesExactMetadataOwnershipMatrix$'" + }, + { + "selector": "TestNormalizeClosesEveryOriginOwnerReference", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeClosesEveryOriginOwnerReference$'" + }, + { + "selector": "TestNormalizeRejectsOverlapAndGapForEveryMetadataField", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestNormalizeRejectsOverlapAndGapForEveryMetadataField$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-024", + "scenarioId": "proofkit.spec-proof-core.requirement-source-v2-expanded-budget-closure", + "witnessId": "proofkit.requirement-source-v2.expanded-budget-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcemodel/projection_budget_test.go", + "witnessSelectors": [ + { + "selector": "TestExpandedProjectionBudgetMatchesIndependentMaterialization", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestExpandedProjectionBudgetMatchesIndependentMaterialization$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-024", + "scenarioId": "proofkit.spec-proof-core.requirement-source-v2-enum-inventory-closure", + "witnessId": "proofkit.requirement-source-v2.enum-inventory-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcemodel/variant_inventory_test.go", + "witnessSelectors": [ + { + "selector": "TestIndependentManifestMatchesExactProductionEnumInventory", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestIndependentManifestMatchesExactProductionEnumInventory$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-024", + "scenarioId": "proofkit.spec-proof-core.requirement-source-v2-field-projection-closure", + "witnessId": "proofkit.requirement-source-v2.field-projection-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcemodel/completeness_test.go", + "witnessSelectors": [ + { + "selector": "TestIndependentManifestClosesEveryFieldThroughRealMutants", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestIndependentManifestClosesEveryFieldThroughRealMutants$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-024", + "scenarioId": "proofkit.spec-proof-core.requirement-source-v2-typed-field-inventory-closure", + "witnessId": "proofkit.requirement-source-v2.typed-field-inventory-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcemodel/field_inventory_test.go", + "witnessSelectors": [ + { + "selector": "TestIndependentManifestMatchesExactTypedInputFieldInventory", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestIndependentManifestMatchesExactTypedInputFieldInventory$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-024", + "scenarioId": "proofkit.spec-proof-core.requirement-source-v2-input-budget-closure", + "witnessId": "proofkit.requirement-source-v2.input-budget-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcemodel/input_budget_test.go", + "witnessSelectors": [ + { + "selector": "TestInputCollectionBudgetMatchesIndependentObserver", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestInputCollectionBudgetMatchesIndependentObserver$'" + }, + { + "selector": "TestInputTextBudgetUsesArchitectureIndependentWidth", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestInputTextBudgetUsesArchitectureIndependentWidth$'" + }, + { + "selector": "TestLimitConfigurationRejectsEveryInvalidBoundary", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestLimitConfigurationRejectsEveryInvalidBoundary$'" + }, + { + "selector": "TestPreflightBudgetErrorPrecedence", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestPreflightBudgetErrorPrecedence$'" + }, + { + "selector": "TestPreflightCardinalityBudgetsHaveExactTransitions", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestPreflightCardinalityBudgetsHaveExactTransitions$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-024", + "scenarioId": "proofkit.spec-proof-core.requirement-source-v2-manifest-admission-closure", + "witnessId": "proofkit.requirement-source-v2.manifest-admission-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcemodel/completeness_test.go", + "witnessSelectors": [ + { + "selector": "TestCompletenessRecordsUseExactStrictJSONAdmission", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestCompletenessRecordsUseExactStrictJSONAdmission$'" + }, + { + "selector": "TestIndependentManifestClosesEnumAndPresenceVariants", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestIndependentManifestClosesEnumAndPresenceVariants$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-024", + "scenarioId": "proofkit.spec-proof-core.requirement-source-v2-package-boundary-closure", + "witnessId": "proofkit.requirement-source-v2.package-boundary-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcemodel/package_boundary_test.go", + "witnessSelectors": [ + { + "selector": "TestRepresentationNeutralPackageBoundaryIsExact", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestRepresentationNeutralPackageBoundaryIsExact$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-024", + "scenarioId": "proofkit.spec-proof-core.requirement-source-v2-relation-negative-closure", + "witnessId": "proofkit.requirement-source-v2.relation-negative-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/requirementsourcemodel/mutant_relation_negative_test.go", + "witnessSelectors": [ + { + "selector": "TestMutantRelationPredicatesRejectNearMisses", + "command": "go test ./internal/kernel/requirementsourcemodel -run '^TestMutantRelationPredicatesRejectNearMisses$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, { "requirementId": "REQ-PROOFKIT-SPEC-023", "scenarioId": "proofkit.spec-proof-core.requirement-traceability-topology-closure",