From d6be72c582485ef791a79c2d3bc69c9ec6e54d9a Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 21 Jun 2026 11:11:06 -0400 Subject: [PATCH 1/3] refactor(graphdb_class): relocate validate_template_scope_in_txn from graphdb_instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper is pure class-domain logic (template-kind check + taxonomy-ancestry scope) — three of its four calls were already into graphdb_class. Moving it turns those into local unqualified calls and removes graphdb_instance reaching into class internals to validate Connection-arc template scope. graphdb_instance now calls graphdb_class:validate_template_scope_in_txn/3; behaviour byte-identical (same abort terms, same phase semantics). A small head_parent/1 helper is duplicated into graphdb_class (in keeping with the codebase's deliberate per-worker micro-helper duplication). Direct CT tests added in graphdb_class_SUITE (in-scope success, non-template, out-of-ancestry); the add_relationship integration tests in graphdb_instance_SUITE stay (they exercise add_relationship end-to-end, a different unit). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- apps/graphdb/src/graphdb_class.erl | 33 +++++++++++++++ apps/graphdb/src/graphdb_instance.erl | 31 +------------- apps/graphdb/test/graphdb_class_SUITE.erl | 49 ++++++++++++++++++++++- 3 files changed, 83 insertions(+), 30 deletions(-) diff --git a/apps/graphdb/src/graphdb_class.erl b/apps/graphdb/src/graphdb_class.erl index ce7ac62..1795947 100644 --- a/apps/graphdb/src/graphdb_class.erl +++ b/apps/graphdb/src/graphdb_class.erl @@ -128,6 +128,7 @@ %% Template AVP class scope on Connection arcs) class_in_ancestry/2, class_in_ancestry_in_txn/2, + validate_template_scope_in_txn/3, %% Inheritance inherited_qcs/1 ]). @@ -854,6 +855,38 @@ walk_ancestors_in_txn([Nref | Rest], Visited, Acc) -> end. +%%----------------------------------------------------------------------------- +%% validate_template_scope_in_txn(TemplateNref, SourceClass, TargetClass) -> ok +%% (aborts invalid_template / template_class_not_in_ancestry) +%% +%% Confirms TemplateNref resolves to a kind=template node whose parent class is +%% in SourceClass's or TargetClass's taxonomic ancestry. Tier-1 in-transaction +%% helper: assumes it runs inside an active mnesia activity and signals failure +%% via mnesia:abort/1. Used by graphdb_instance to validate the Template AVP +%% class scope on Connection arcs. +%%----------------------------------------------------------------------------- +validate_template_scope_in_txn(TemplateNref, SourceClass, TargetClass) -> + case get_template_in_txn(TemplateNref) of + {ok, #node{parents = TmplParents}} -> + TmplClass = head_parent(TmplParents), + InSource = class_in_ancestry_in_txn(TmplClass, SourceClass), + InTarget = class_in_ancestry_in_txn(TmplClass, TargetClass), + case InSource orelse InTarget of + true -> ok; + false -> mnesia:abort({template_class_not_in_ancestry, + TemplateNref, TmplClass, SourceClass, TargetClass}) + end; + {error, Reason} -> + mnesia:abort({invalid_template, TemplateNref, Reason}) + end. + +%% head_parent(Parents) -> integer() | undefined +%% +%% First element of a node's parents cache, or undefined when empty. +head_parent([]) -> undefined; +head_parent([P | _]) -> P. + + %%----------------------------------------------------------------------------- %% do_validate_parent(ParentNref) -> ok | {error, term()} %% diff --git a/apps/graphdb/src/graphdb_instance.erl b/apps/graphdb/src/graphdb_instance.erl index c5174eb..cbd9327 100644 --- a/apps/graphdb/src/graphdb_instance.erl +++ b/apps/graphdb/src/graphdb_instance.erl @@ -1201,8 +1201,8 @@ do_add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref, {SourceClass, TargetClass} = resolve_arc_classes_in_txn(SourceNref, TargetNref), TemplateNref = resolve_template_in_txn(TemplateSpec, SourceClass), - ok = validate_template_scope_in_txn(TemplateNref, SourceClass, - TargetClass), + ok = graphdb_class:validate_template_scope_in_txn(TemplateNref, + SourceClass, TargetClass), Rows = build_connection_rows(IdPair, SourceNref, CharNref, TargetNref, ReciprocalNref, TemplateNref, AVPSpec), lists:foreach(fun({Tab, Rec}) -> ok = mnesia:write(Tab, Rec, write) end, @@ -1320,33 +1320,6 @@ resolve_template_in_txn(TemplateNref, _SourceClass) TemplateNref. -%%----------------------------------------------------------------------------- -%% validate_template_scope_in_txn(TemplateNref, SourceClass, TargetClass) -> ok -%% (aborts invalid_template / template_class_not_in_ancestry) -%% -%% Confirms TemplateNref resolves to a kind=template node whose parent class is -%% in SourceClass's or TargetClass's taxonomic ancestry. The nested Reason in -%% {invalid_template, _, Reason} is byte-identical to the gen-server get_template -%% form: get_template_in_txn returns the same {error, not_a_template|not_found}. -%%----------------------------------------------------------------------------- -validate_template_scope_in_txn(TemplateNref, SourceClass, TargetClass) -> - case graphdb_class:get_template_in_txn(TemplateNref) of - {ok, #node{parents = TmplParents}} -> - TmplClass = head_parent(TmplParents), - InSource = graphdb_class:class_in_ancestry_in_txn(TmplClass, - SourceClass), - InTarget = graphdb_class:class_in_ancestry_in_txn(TmplClass, - TargetClass), - case InSource orelse InTarget of - true -> ok; - false -> mnesia:abort({template_class_not_in_ancestry, - TemplateNref, TmplClass, SourceClass, TargetClass}) - end; - {error, Reason} -> - mnesia:abort({invalid_template, TemplateNref, Reason}) - end. - - %%----------------------------------------------------------------------------- %% build_connection_rows(S, C, T, R, TemplateNref, {FwdAVPs, RevAVPs}) %% -> [{relationships, #relationship{}}] diff --git a/apps/graphdb/test/graphdb_class_SUITE.erl b/apps/graphdb/test/graphdb_class_SUITE.erl index 843bb24..8c467d9 100644 --- a/apps/graphdb/test/graphdb_class_SUITE.erl +++ b/apps/graphdb/test/graphdb_class_SUITE.erl @@ -91,6 +91,9 @@ class_in_ancestry_in_txn_ancestor/1, class_in_ancestry_in_txn_unrelated/1, class_in_ancestry_in_txn_diamond/1, + validate_template_scope_in_txn_in_scope/1, + validate_template_scope_in_txn_rejects_non_template/1, + validate_template_scope_in_txn_rejects_out_of_ancestry/1, %% Qualifying characteristics add_qc_basic/1, add_qc_idempotent/1, @@ -174,7 +177,10 @@ groups() -> class_in_ancestry_in_txn_self, class_in_ancestry_in_txn_ancestor, class_in_ancestry_in_txn_unrelated, - class_in_ancestry_in_txn_diamond + class_in_ancestry_in_txn_diamond, + validate_template_scope_in_txn_in_scope, + validate_template_scope_in_txn_rejects_non_template, + validate_template_scope_in_txn_rejects_out_of_ancestry ]}, {qualifying, [], [ add_qc_basic, @@ -701,6 +707,47 @@ class_in_ancestry_in_txn_diamond(_Config) -> graphdb_class:class_in_ancestry_in_txn(A, D) end)). +%%----------------------------------------------------------------------------- +%% validate_template_scope_in_txn accepts a template whose parent class is in +%% the source class's ancestry (success returns ok -> {ok, ok}). +%%----------------------------------------------------------------------------- +validate_template_scope_in_txn_in_scope(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, ClassNref} = graphdb_class:create_class("Person", 3), + {ok, Tmpl} = graphdb_class:default_template(ClassNref), + ?assertEqual({ok, ok}, graphdb_mgr:transaction(fun() -> + graphdb_class:validate_template_scope_in_txn(Tmpl, ClassNref, ClassNref) + end)). + +%%----------------------------------------------------------------------------- +%% validate_template_scope_in_txn rejects an nref that is not a template node, +%% aborting {invalid_template, Nref, not_a_template}. +%%----------------------------------------------------------------------------- +validate_template_scope_in_txn_rejects_non_template(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, ClassNref} = graphdb_class:create_class("Animal", 3), + ?assertEqual({error, {invalid_template, ClassNref, not_a_template}}, + graphdb_mgr:transaction(fun() -> + graphdb_class:validate_template_scope_in_txn(ClassNref, ClassNref, + ClassNref) + end)). + +%%----------------------------------------------------------------------------- +%% validate_template_scope_in_txn rejects a template whose parent class is in +%% neither the source nor the target class's ancestry. +%%----------------------------------------------------------------------------- +validate_template_scope_in_txn_rejects_out_of_ancestry(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, AnimalCls} = graphdb_class:create_class("Animal", 3), + {ok, VehicleCls} = graphdb_class:create_class("Vehicle", 3), + {ok, VehTmpl} = graphdb_class:default_template(VehicleCls), + ?assertEqual({error, {template_class_not_in_ancestry, VehTmpl, VehicleCls, + AnimalCls, AnimalCls}}, + graphdb_mgr:transaction(fun() -> + graphdb_class:validate_template_scope_in_txn(VehTmpl, AnimalCls, + AnimalCls) + end)). + %%============================================================================= %% Qualifying Characteristic Tests From ee0a896240167aa62c96961ced18b0d0a28200cc Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 21 Jun 2026 11:14:40 -0400 Subject: [PATCH 2/3] refactor(graphdb_class): relocate class-taxonomy value search from graphdb_instance search_class_taxonomy/2 + search_first_in_ancestors/2 are pure class-domain reads (walk a class + its taxonomy ancestors for a bound AVP); both their non-trivial calls were already into graphdb_class (get_class/1, ancestors/1). Moving them turns those into local calls and makes the helper a public graphdb_class read, sibling to class_in_ancestry/2. graphdb_instance's collect_class_hits/2 (the Priority-2 class-value inheritance step) now calls graphdb_class:search_class_taxonomy/2; the resolve_value orchestration and ambiguity policy stay in graphdb_instance. Behaviour-identical (still reads via the class gen_server's public get_class/ancestors). A find_avp_value/2 twin is duplicated into graphdb_class (per the codebase's per-worker micro-helper convention); graphdb_instance keeps its own (4 other callers). +3 direct CT tests in graphdb_class_SUITE (on-class, on-ancestor, not-found); resolve_value inheritance tests in graphdb_instance_SUITE stay. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- apps/graphdb/src/graphdb_class.erl | 57 ++++++++++++++++++++++- apps/graphdb/src/graphdb_instance.erl | 32 +------------ apps/graphdb/test/graphdb_class_SUITE.erl | 44 ++++++++++++++++- 3 files changed, 100 insertions(+), 33 deletions(-) diff --git a/apps/graphdb/src/graphdb_class.erl b/apps/graphdb/src/graphdb_class.erl index 1795947..5d41cb9 100644 --- a/apps/graphdb/src/graphdb_class.erl +++ b/apps/graphdb/src/graphdb_class.erl @@ -130,7 +130,8 @@ class_in_ancestry_in_txn/2, validate_template_scope_in_txn/3, %% Inheritance - inherited_qcs/1 + inherited_qcs/1, + search_class_taxonomy/2 ]). %%--------------------------------------------------------------------- @@ -887,6 +888,60 @@ head_parent([]) -> undefined; head_parent([P | _]) -> P. +%%----------------------------------------------------------------------------- +%% search_class_taxonomy(ClassNref, AttrNref) -> +%% {ok, FoundClassNref, Value} | not_found +%% +%% Walks ClassNref and its taxonomy ancestors (nearest-first), returning the +%% first bound-AVP match together with the class nref where it was found. Used +%% by graphdb_instance's attribute-inheritance resolution (Priority 2: class- +%% level bound values). Runs in the caller's process and reads via the public +%% get_class/1 + ancestors/1 reads (behaviour-identical to its prior home in +%% graphdb_instance). +%%----------------------------------------------------------------------------- +search_class_taxonomy(ClassNref, AttrNref) -> + case get_class(ClassNref) of + {ok, #node{attribute_value_pairs = AVPs}} -> + case find_avp_value(AVPs, AttrNref) of + {ok, V} -> + {ok, ClassNref, V}; + not_found -> + case ancestors(ClassNref) of + {ok, Ancestors} -> + search_first_in_ancestors(Ancestors, AttrNref); + _ -> + not_found + end + end; + _ -> + not_found + end. + +search_first_in_ancestors([], _AttrNref) -> + not_found; +search_first_in_ancestors( + [#node{nref = N, attribute_value_pairs = AVPs} | Rest], AttrNref) -> + case find_avp_value(AVPs, AttrNref) of + {ok, V} -> {ok, N, V}; + not_found -> search_first_in_ancestors(Rest, AttrNref) + end. + +%% find_avp_value(AVPs, AttrNref) -> {ok, Value} | not_found +%% +%% First AVP entry matching AttrNref with a bound (non-undefined) value. An +%% entry with value => undefined is a QC declaration, not a resolved value, and +%% reads as not_found. (Duplicated from graphdb_instance per the codebase's +%% deliberate per-worker micro-helper convention — no shared util module.) +find_avp_value([], _AttrNref) -> + not_found; +find_avp_value([#{attribute := A, value := V} | _], A) when V =/= undefined -> + {ok, V}; +find_avp_value([#{attribute := A} | _], A) -> + not_found; +find_avp_value([_ | Rest], AttrNref) -> + find_avp_value(Rest, AttrNref). + + %%----------------------------------------------------------------------------- %% do_validate_parent(ParentNref) -> ok | {error, term()} %% diff --git a/apps/graphdb/src/graphdb_instance.erl b/apps/graphdb/src/graphdb_instance.erl index cbd9327..13c0982 100644 --- a/apps/graphdb/src/graphdb_instance.erl +++ b/apps/graphdb/src/graphdb_instance.erl @@ -1641,7 +1641,7 @@ resolve_from_class(InstNref, AttrNref) -> collect_class_hits(Classes, AttrNref) -> lists:foldr( fun(ClassNref, Acc) -> - case search_class_taxonomy(ClassNref, AttrNref) of + case graphdb_class:search_class_taxonomy(ClassNref, AttrNref) of {ok, FoundClass, Value} -> [{FoundClass, Value} | Acc]; not_found -> Acc end @@ -1657,36 +1657,6 @@ classify_class_hits([{ClassNref, _} | _] = Hits, AttrNref) -> _ -> {error, {ambiguous_class_value, AttrNref, Hits}} end. -%% Walks ClassNref and its taxonomy ancestors (nearest-first), returning -%% the first AVP match together with the class nref where it was found. -search_class_taxonomy(ClassNref, AttrNref) -> - case graphdb_class:get_class(ClassNref) of - {ok, #node{attribute_value_pairs = AVPs}} -> - case find_avp_value(AVPs, AttrNref) of - {ok, V} -> - {ok, ClassNref, V}; - not_found -> - case graphdb_class:ancestors(ClassNref) of - {ok, Ancestors} -> - search_first_in_ancestors(Ancestors, AttrNref); - _ -> - not_found - end - end; - _ -> - not_found - end. - -search_first_in_ancestors([], _AttrNref) -> - not_found; -search_first_in_ancestors( - [#node{nref = N, attribute_value_pairs = AVPs} | Rest], AttrNref) -> - case find_avp_value(AVPs, AttrNref) of - {ok, V} -> {ok, N, V}; - not_found -> search_first_in_ancestors(Rest, AttrNref) - end. - - %%----------------------------------------------------------------------------- %% resolve_from_ancestors(ParentNref, AttrNref) -> %% {ok, Value, AncestorNref} | not_found | {error, term()} diff --git a/apps/graphdb/test/graphdb_class_SUITE.erl b/apps/graphdb/test/graphdb_class_SUITE.erl index 8c467d9..874d22a 100644 --- a/apps/graphdb/test/graphdb_class_SUITE.erl +++ b/apps/graphdb/test/graphdb_class_SUITE.erl @@ -94,6 +94,9 @@ validate_template_scope_in_txn_in_scope/1, validate_template_scope_in_txn_rejects_non_template/1, validate_template_scope_in_txn_rejects_out_of_ancestry/1, + search_class_taxonomy_on_class/1, + search_class_taxonomy_on_ancestor/1, + search_class_taxonomy_not_found/1, %% Qualifying characteristics add_qc_basic/1, add_qc_idempotent/1, @@ -180,7 +183,10 @@ groups() -> class_in_ancestry_in_txn_diamond, validate_template_scope_in_txn_in_scope, validate_template_scope_in_txn_rejects_non_template, - validate_template_scope_in_txn_rejects_out_of_ancestry + validate_template_scope_in_txn_rejects_out_of_ancestry, + search_class_taxonomy_on_class, + search_class_taxonomy_on_ancestor, + search_class_taxonomy_not_found ]}, {qualifying, [], [ add_qc_basic, @@ -748,6 +754,42 @@ validate_template_scope_in_txn_rejects_out_of_ancestry(_Config) -> AnimalCls) end)). +%%----------------------------------------------------------------------------- +%% search_class_taxonomy finds a value bound directly on the class node. +%%----------------------------------------------------------------------------- +search_class_taxonomy_on_class(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, ClassNref} = graphdb_class:create_class("Color", 3), + ok = graphdb_class:add_qualifying_characteristic(ClassNref, + ?NAME_ATTR_ATTRIBUTE), + ok = graphdb_class:bind_qc_value(ClassNref, ?NAME_ATTR_ATTRIBUTE, "blue"), + ?assertEqual({ok, ClassNref, "blue"}, + graphdb_class:search_class_taxonomy(ClassNref, ?NAME_ATTR_ATTRIBUTE)). + +%%----------------------------------------------------------------------------- +%% search_class_taxonomy finds a value bound on a taxonomy ancestor when the +%% class itself does not carry it; reports the ancestor where it was found. +%%----------------------------------------------------------------------------- +search_class_taxonomy_on_ancestor(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, Parent} = graphdb_class:create_class("Animal", 3), + {ok, Child} = graphdb_class:create_class("Mammal", Parent), + ok = graphdb_class:add_qualifying_characteristic(Parent, + ?NAME_ATTR_ATTRIBUTE), + ok = graphdb_class:bind_qc_value(Parent, ?NAME_ATTR_ATTRIBUTE, "warm"), + ?assertEqual({ok, Parent, "warm"}, + graphdb_class:search_class_taxonomy(Child, ?NAME_ATTR_ATTRIBUTE)). + +%%----------------------------------------------------------------------------- +%% search_class_taxonomy returns not_found when neither the class nor any +%% ancestor binds the attribute. +%%----------------------------------------------------------------------------- +search_class_taxonomy_not_found(_Config) -> + {ok, _} = graphdb_class:start_link(), + {ok, ClassNref} = graphdb_class:create_class("Color", 3), + ?assertEqual(not_found, + graphdb_class:search_class_taxonomy(ClassNref, ?NAME_ATTR_ATTRIBUTE)). + %%============================================================================= %% Qualifying Characteristic Tests From a1c508bc589f7f2d9d7170a3bf75405f4e0a6a3b Mon Sep 17 00:00:00 2001 From: "David W. Thomas" Date: Sun, 21 Jun 2026 11:29:29 -0400 Subject: [PATCH 3/3] docs(graphdb_class): document relocated validate_template_scope_in_txn + search_class_taxonomy Add the two relocated functions to the graphdb_class API section of the worker CLAUDE.md, and refine the export-list grouping comments. Architecture.md is intentionally untouched: the relocations are internal refactors that leave the public contract (resolve_value/2, add_relationship signatures) and the documented inheritance algorithm unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWukKCbrN8GybaScJGU2kF --- apps/graphdb/CLAUDE.md | 14 ++++++++++++++ apps/graphdb/src/graphdb_class.erl | 7 ++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/graphdb/CLAUDE.md b/apps/graphdb/CLAUDE.md index 94653c5..4583eee 100644 --- a/apps/graphdb/CLAUDE.md +++ b/apps/graphdb/CLAUDE.md @@ -254,6 +254,20 @@ Manages the "is a" hierarchy of class nodes in the ontology. transaction (the seam's tier-1 contract) and are the prerequisite for atomic `add_relationship` / `mutate/1`. See `docs/designs/atomic-add-relationship-primitives-design.md`. +- `validate_template_scope_in_txn/3` (template_nref, source_class, + target_class) — in-transaction helper (aborts on failure): confirms the + template is a `kind=template` node whose parent class is in the source's or + target's taxonomic ancestry, else aborts `{template_class_not_in_ancestry, + …}` / `{invalid_template, …, Reason}`. Called by + `graphdb_instance:add_relationship` to validate Connection-arc template scope + (relocated here from `graphdb_instance` — it is pure class-domain logic). +- `search_class_taxonomy/2` (class_nref, attr_nref) — walks a class and its + taxonomy ancestors (nearest-first) for the first bound AVP, returning + `{ok, FoundClassNref, Value} | not_found`. Reads via the public + `get_class/1` + `ancestors/1` (not an in-txn primitive). Backs + `graphdb_instance`'s Priority-2 (class-bound) attribute inheritance + (relocated here from `graphdb_instance`); the per-instance membership + orchestration and ambiguity policy stay in `graphdb_instance`. ### `graphdb_instance` — Instance & Compositional Hierarchy diff --git a/apps/graphdb/src/graphdb_class.erl b/apps/graphdb/src/graphdb_class.erl index 5d41cb9..6090855 100644 --- a/apps/graphdb/src/graphdb_class.erl +++ b/apps/graphdb/src/graphdb_class.erl @@ -124,12 +124,13 @@ default_template/1, default_template_in_txn/1, is_instantiable/1, - %% Class-of resolution helper (used by graphdb_instance to validate - %% Template AVP class scope on Connection arcs) + %% Class-ancestry + template-scope helpers (used by graphdb_instance + %% to validate Template AVP class scope on Connection arcs) class_in_ancestry/2, class_in_ancestry_in_txn/2, validate_template_scope_in_txn/3, - %% Inheritance + %% Inheritance (search_class_taxonomy backs graphdb_instance's + %% Priority-2 class-bound attribute resolution) inherited_qcs/1, search_class_taxonomy/2 ]).