From a9a10ae6b0b68f02499ac2bc149dd9a2e2708573 Mon Sep 17 00:00:00 2001 From: iperev Date: Sun, 20 Sep 2026 20:24:38 +0200 Subject: [PATCH] perf: reduce private source model materialization copies --- .../accessor_immutability_test.go | 37 +++++++++ .../group_normalization.go | 9 ++- .../normalization_cost_test.go | 77 +++++++++++++++++++ .../requirementsourcemodel/normalize.go | 6 +- internal/tools/releasechange/record_test.go | 8 +- package-lock.json | 4 +- package.json | 2 +- release/change-record.v2.json | 8 +- 8 files changed, 136 insertions(+), 15 deletions(-) create mode 100644 internal/kernel/requirementsourcemodel/normalization_cost_test.go diff --git a/internal/kernel/requirementsourcemodel/accessor_immutability_test.go b/internal/kernel/requirementsourcemodel/accessor_immutability_test.go index df078f7b..b7286a5b 100644 --- a/internal/kernel/requirementsourcemodel/accessor_immutability_test.go +++ b/internal/kernel/requirementsourcemodel/accessor_immutability_test.go @@ -5,6 +5,43 @@ import ( "testing" ) +func TestNormalizeOwnsDetachedInputAcrossCalls(t *testing.T) { + for _, owner := range []MetadataOwnerKind{MetadataOwnerMember, MetadataOwnerProfile} { + t.Run(string(owner), func(t *testing.T) { + draft := validDraft() + applyMetadataVariantForOwner(&draft, "claimLevel", string(ClaimDeferred), owner) + model, err := Normalize(draft) + if err != nil { + t.Fatal(err) + } + atomic := detachedTestCopy(model.Atomic()) + layout := detachedTestCopy(model.Layout()) + references := detachedTestCopy(model.References()) + assertUnchanged := func(step string) { + t.Helper() + if !reflect.DeepEqual(model.Atomic(), atomic) || !reflect.DeepEqual(model.Layout(), layout) || !reflect.DeepEqual(model.References(), references) { + t.Fatalf("%s changed an admitted model", step) + } + } + if mutateReferencedState(reflect.ValueOf(&draft).Elem(), false) == 0 { + t.Fatal("draft fixture did not expose mutable references") + } + assertUnchanged("caller mutation") + other := validDraft() + other.SourceID = "source.second" + other.SourceNonClaims = []string{"A separate source boundary."} + other.Groups[0].StatementStem = "The API must" + if _, err := Normalize(other); err != nil { + t.Fatal(err) + } + assertUnchanged("later normalization") + assertAccessorReturnsDetachedState(t, "atomic", model.Atomic) + assertAccessorReturnsDetachedState(t, "layout", model.Layout) + assertAccessorReturnsDetachedState(t, "references", model.References) + }) + } +} + func assertAccessorReturnsDetachedState[T any](t *testing.T, name string, accessor func() T) { t.Helper() baseline := detachedTestCopy(accessor()) diff --git a/internal/kernel/requirementsourcemodel/group_normalization.go b/internal/kernel/requirementsourcemodel/group_normalization.go index 40248b4f..be2104c8 100644 --- a/internal/kernel/requirementsourcemodel/group_normalization.go +++ b/internal/kernel/requirementsourcemodel/group_normalization.go @@ -6,8 +6,13 @@ func normalizeGroups(values []Group, profiles map[string]Profile) ([]Group, []At groups := make([]Group, len(values)) groupIDs := make(map[string]struct{}, len(values)) requirementIDs := map[string]struct{}{} - requirements := []AtomicRequirement{} - origins := []Origin{} + // NormalizeWithLimits has already bounded the complete snapshot membership. + memberCount := 0 + for _, group := range values { + memberCount += len(group.Members) + } + requirements := make([]AtomicRequirement, 0, memberCount) + origins := make([]Origin, 0, memberCount) profileUses := map[string]int{} for groupIndex, value := range values { diff --git a/internal/kernel/requirementsourcemodel/normalization_cost_test.go b/internal/kernel/requirementsourcemodel/normalization_cost_test.go new file mode 100644 index 00000000..0362718c --- /dev/null +++ b/internal/kernel/requirementsourcemodel/normalization_cost_test.go @@ -0,0 +1,77 @@ +package requirementsourcemodel + +import ( + "fmt" + "testing" +) + +func flatRequirementDraft(count int) Draft { + draft := Draft{SourceID: "source.resource-cost", SpecPackagePath: "docs/specs/resource-cost", + SourceNonClaims: []string{"Synthetic resource observation, not execution authority."}} + if count == 0 { + return draft + } + draft.Groups = []Group{{GroupID: "RGRP-RESOURCE", Members: make([]Member, count)}} + for index := range draft.Groups[0].Members { + draft.Groups[0].Members[index] = Member{ + RequirementID: fmt.Sprintf("REQ-RESOURCE-%05d", index), + StatementCompletion: "The operation preserves the declared input value.", + Fields: MetadataFields{ + OwnerID: Own("owner.resource"), ClaimLevel: Own(ClaimAdvisory), RiskClass: Own(RiskLow), + NonClaims: Own([]string{"No native witness is asserted."}), + ExternalNonClaimRefs: Own([]string{}), NonClaimRefs: Own([]string{}), ProofBindingRefs: Own([]string{}), + Lifecycle: Own(Lifecycle{State: LifecycleActive}), Deferral: Own[*Deferral](nil), + UpdatePolicy: Own(UpdatePolicy{ReviewOwnerID: "owner.resource"}), + }, + } + } + return draft +} + +func TestNormalizedMemberStorageMatchesAdmittedCardinality(t *testing.T) { + for _, count := range []int{0, 1, 5, 64, 512, 4096} { + t.Run(fmt.Sprint(count), func(t *testing.T) { + draft := flatRequirementDraft(count) + if count == 5 { + group := draft.Groups[0] + draft.Groups = []Group{ + {GroupID: "RGRP-FIRST", Members: group.Members[:2]}, + {GroupID: "RGRP-SECOND", Members: group.Members[2:]}, + } + } + model, err := Normalize(draft) + if err != nil { + t.Fatal(err) + } + if len(model.atomic.Requirements) != count || cap(model.atomic.Requirements) != count || len(model.layout.Origins) != count || cap(model.layout.Origins) != count { + t.Fatal("model retained excess or incomplete member projection storage") + } + compactedEdges := append([]ReferenceEdge(nil), model.references.Edges...) + if cap(model.references.Edges) != cap(compactedEdges) { + t.Fatal("model retained uncompacted reference edge storage") + } + }) + } +} + +var normalizationBenchmarkSink Model + +func BenchmarkNormalizeMemberStorage(b *testing.B) { + for _, count := range []int{64, 512, 4096} { + b.Run(fmt.Sprint(count), func(b *testing.B) { + draft := flatRequirementDraft(count) + if _, err := Normalize(draft); err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + model, err := Normalize(draft) + if err != nil { + b.Fatal(err) + } + normalizationBenchmarkSink = model + } + }) + } +} diff --git a/internal/kernel/requirementsourcemodel/normalize.go b/internal/kernel/requirementsourcemodel/normalize.go index ecb4a58d..9cec5060 100644 --- a/internal/kernel/requirementsourcemodel/normalize.go +++ b/internal/kernel/requirementsourcemodel/normalize.go @@ -98,9 +98,11 @@ func NormalizeWithLimits(draft Draft, limits Limits) (Model, error) { } layout := LayoutProjection{SourceID: sourceID, Profiles: profiles, Groups: groups, Origins: origins} references := ReferenceProjection{SourceID: sourceID, Derivations: derivations, Edges: edges} + // Atomic and layout values belong to this detached snapshot; accessors copy + // them on exit. Reference cloning also trims its append-built edge storage. return Model{ - atomic: cloneAtomicProjection(atomic), - layout: cloneLayoutProjection(layout), + atomic: atomic, + layout: layout, references: cloneReferenceProjection(references), }, nil } diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go index 26499f41..db56fa14 100644 --- a/internal/tools/releasechange/record_test.go +++ b/internal/tools/releasechange/record_test.go @@ -197,7 +197,7 @@ func TestCurrentChangeRecordNamesReviewedSemanticChanges(t *testing.T) { var currentBreakingChanges = []Change{} var currentAdditions = []Change{ - {ChangeID: "proofkit.traceability.input-guidance", Summary: "Add lazy CLI recipes for source-intake roles, canonical catalog/tree composition and current-subject review of the exact publication plane. Strengthen connected input and normative-guidance regression oracles and explain scenario-storage ownership. Public source formats, command input/output semantics, runtime admission, native execution authority, dependencies and supported platforms remain unchanged."}, + {ChangeID: "proofkit.source-model.materialization", Summary: "Remove redundant atomic and layout copies from private requirement-source normalization, size member projections from the admitted snapshot, and retain reference compaction and detached public accessors. Add allocation benchmarks and independent caller-mutation, later-normalization and retained-capacity checks. Public source formats, CLI semantics, resource limits, dependencies and supported platforms remain unchanged; no source-v2 cutover is introduced."}, } var currentMigrationSteps = []string{} @@ -220,7 +220,7 @@ func validateCurrentChangeRecord(record Record, notes string) error { func currentExpectedReleaseNotes() string { lines := []string{ - "# @research-engineering/agentic-proofkit 0.14.19", + "# @research-engineering/agentic-proofkit 0.14.20", "", "## Breaking Contract Changes", "", @@ -273,7 +273,7 @@ func currentExpectedReleaseNotes() string { "Primary npm channel:", "", "```bash", - "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.19", + "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.20", "```", "", "Pre-1.0 npm consumers must keep this dependency exact-pinned.", @@ -285,7 +285,7 @@ func currentExpectedReleaseNotes() string { "## Rollback", "", "- First follow the migration and persistent-state compatibility restrictions above; changing a package pin does not roll back repository state.", - "- Pin npm consumers to the previous admitted version 0.14.18 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.18`.", + "- Pin npm consumers to the previous admitted version 0.14.19 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.19`.", "- Treat local package artifacts as candidates until registry identity is proven.", ) return strings.Join(lines, "\n") + "\n" diff --git a/package-lock.json b/package-lock.json index 5cdaf602..bf270880 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@research-engineering/agentic-proofkit", - "version": "0.14.19", + "version": "0.14.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@research-engineering/agentic-proofkit", - "version": "0.14.19", + "version": "0.14.20", "cpu": [ "arm64", "x64" diff --git a/package.json b/package.json index 8a708e11..1bac08d4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@research-engineering/agentic-proofkit", "description": "Reusable proof profile, report, graph, and witness-planning primitives.", - "version": "0.14.19", + "version": "0.14.20", "type": "module", "license": "MIT", "sideEffects": false, diff --git a/release/change-record.v2.json b/release/change-record.v2.json index e49ab613..1e3df011 100644 --- a/release/change-record.v2.json +++ b/release/change-record.v2.json @@ -1,13 +1,13 @@ { "schemaVersion": 2, - "previousVersion": "0.14.18", - "version": "0.14.19", + "previousVersion": "0.14.19", + "version": "0.14.20", "changeClass": "compatible", "breakingChanges": [], "additions": [ { - "changeId": "proofkit.traceability.input-guidance", - "summary": "Add lazy CLI recipes for source-intake roles, canonical catalog/tree composition and current-subject review of the exact publication plane. Strengthen connected input and normative-guidance regression oracles and explain scenario-storage ownership. Public source formats, command input/output semantics, runtime admission, native execution authority, dependencies and supported platforms remain unchanged." + "changeId": "proofkit.source-model.materialization", + "summary": "Remove redundant atomic and layout copies from private requirement-source normalization, size member projections from the admitted snapshot, and retain reference compaction and detached public accessors. Add allocation benchmarks and independent caller-mutation, later-normalization and retained-capacity checks. Public source formats, CLI semantics, resource limits, dependencies and supported platforms remain unchanged; no source-v2 cutover is introduced." } ], "migration": {