diff --git a/CLAUDE.md b/CLAUDE.md index 8b27b69..cc25b49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -275,7 +275,7 @@ A logical bidirectional edge is two `relationship` rows written atomically (one These are outstanding items — all previously known bugs have been fixed. -- **`graphdb_rules` rule-firing engine** — the rule meta-ontology, create/retrieve, taxonomy walk, composition firing, propose mode, and connection firing are implemented. Conflict precedence and the later firing-engine phases (instantiation engine, reactive learning) remain outstanding (see `TASKS.md`) +- **`graphdb_rules` rule-firing engine** — the rule meta-ontology, create/retrieve, taxonomy walk, composition firing, propose mode, connection firing, and horizontal conflict precedence are implemented. The later firing-engine phases (instantiation engine, reactive learning) remain outstanding (see `TASKS.md`) - **`graphdb_mgr` write operations** — `create_attribute/3`, `create_class/2`, `create_instance/3`, `add_relationship/4` delegate to the workers; `delete_node/1` and `update_node_avps/2` still return `{error, not_implemented}` pending a worker that implements them - **`code_change/3`** — deferred in every gen_server until the first hot-upgrade deployment (see `TASKS.md`) - **App lifecycle callbacks** — `start_phase/3`, `prep_stop/1`, `stop/1`, `config_change/3` return `ok` (no-op) across all five app modules; correct for current deployment model diff --git a/README.md b/README.md index 1712684..0f89825 100644 --- a/README.md +++ b/README.md @@ -16,20 +16,20 @@ The project compiles clean with zero warnings (OTP 27 / rebar3 3.24). The architecture is fully designed (see [`docs/Architecture.md`](docs/Architecture.md)). Implementation is underway: -| Component | Status | -| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `nref` subsystem | Fully implemented (DETS — Disk-based Erlang Term Storage — backed ID allocator with `set_floor/1`) | -| `dictionary` subsystem | `dictionary_imp` implemented; `dictionary_server` / `term_server` wired to it | -| `graphdb_bootstrap` | Fully implemented — Mnesia (Erlang's built-in distributed database) schema/table creation, bootstrap scaffold loader (38 nodes, 38 relationship pairs) | -| `graphdb_mgr` | Implemented — bootstrap init, public read API (`get_node`, `get_relationships`), category immutability guard, cache audit/repair (`verify_caches/0`, `rebuild_caches/0`); write operations delegate to workers | -| `graphdb_attr` | Fully implemented — attribute library (name, literal, relationship attributes, relationship types) | -| `graphdb_class` | Fully implemented — taxonomic hierarchy with multi-parent inheritance (BFS — breadth-first search — over a DAG, a directed acyclic graph), qualifying characteristics, class-level inheritance | -| `graphdb_instance` | Fully implemented — compositional hierarchy, multi-class membership, four-level inheritance with class-resolver ambiguity detection; fires composition rules on `create_instance/3`, surfaces `proposed` outcomes for propose-mode rules, and fires connection rules via a caller-supplied resolver on `create_instance/4` | -| `graphdb_rules` | Implemented — rule meta-ontology + create/retrieve; `effective_rules_for_class/2` + `effective_connection_rules/2` (taxonomy walk); composition firing engine; propose mode; connection firing; conflict precedence and the later firing-engine phases outstanding (see `TASKS.md`) | -| `graphdb_language` | Fully implemented — multilingual overlay (language registration, dialect chains, per-language overlay tables, label resolution, translation hooks) | -| `graphdb_query` | Implemented — query language (parse/execute, snapshot-semantics sessions, path finding) | - -**509 tests** (105 EUnit + 404 Common Test) — all passing. See +| Component | Status | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `nref` subsystem | Fully implemented (DETS — Disk-based Erlang Term Storage — backed ID allocator with `set_floor/1`) | +| `dictionary` subsystem | `dictionary_imp` implemented; `dictionary_server` / `term_server` wired to it | +| `graphdb_bootstrap` | Fully implemented — Mnesia (Erlang's built-in distributed database) schema/table creation, bootstrap scaffold loader (38 nodes, 38 relationship pairs) | +| `graphdb_mgr` | Implemented — bootstrap init, public read API (`get_node`, `get_relationships`), category immutability guard, cache audit/repair (`verify_caches/0`, `rebuild_caches/0`); write operations delegate to workers | +| `graphdb_attr` | Fully implemented — attribute library (name, literal, relationship attributes, relationship types) | +| `graphdb_class` | Fully implemented — taxonomic hierarchy with multi-parent inheritance (BFS — breadth-first search — over a DAG, a directed acyclic graph), qualifying characteristics, class-level inheritance | +| `graphdb_instance` | Fully implemented — compositional hierarchy, multi-class membership, four-level inheritance with class-resolver ambiguity detection; fires composition rules on `create_instance/3`, surfaces `proposed` outcomes for propose-mode rules, fires connection rules via a caller-supplied resolver on `create_instance/4`, and applies horizontal conflict precedence via a caller-overridable resolver on `create_instance/5` | +| `graphdb_rules` | Implemented — rule meta-ontology + create/retrieve; `effective_rules_for_class/2` + `effective_connection_rules/2` (taxonomy walk); composition firing engine; propose mode; connection firing; horizontal conflict precedence; the later firing-engine phases outstanding (see `TASKS.md`) | +| `graphdb_language` | Fully implemented — multilingual overlay (language registration, dialect chains, per-language overlay tables, label resolution, translation hooks) | +| `graphdb_query` | Implemented — query language (parse/execute, snapshot-semantics sessions, path finding) | + +**523 tests** (105 EUnit + 418 Common Test) — all passing. See `TASKS.md` for remaining work. --- @@ -192,15 +192,15 @@ Priority order — each step applies only to attributes not yet resolved by a hi ### graphdb Workers -| Module | Role | -| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `graphdb_attr` | Attribute library — name attributes, literal attributes, relationship attributes, relationship types | -| `graphdb_class` | Taxonomic hierarchy — class nodes, qualifying characteristics, class inheritance | -| `graphdb_instance` | Instance nodes — creation, retrieval, compositional hierarchy | -| `graphdb_rules` | Graph rules — rule meta-ontology + create/retrieve; taxonomy-walk effective-rules reads; composition firing engine; propose mode; connection firing; conflict precedence and later phases outstanding | -| `graphdb_language` | Multilingual overlay — language registration, dialect chains, per-language overlay tables, label resolution | -| `graphdb_query` | Query language — parses and executes graph queries; snapshot-semantics sessions | -| `graphdb_mgr` | Primary coordinator — routes operations across the other specialized workers | +| Module | Role | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `graphdb_attr` | Attribute library — name attributes, literal attributes, relationship attributes, relationship types | +| `graphdb_class` | Taxonomic hierarchy — class nodes, qualifying characteristics, class inheritance | +| `graphdb_instance` | Instance nodes — creation, retrieval, compositional hierarchy | +| `graphdb_rules` | Graph rules — rule meta-ontology + create/retrieve; taxonomy-walk effective-rules reads; composition firing engine; propose mode; connection firing; horizontal conflict precedence; later phases outstanding | +| `graphdb_language` | Multilingual overlay — language registration, dialect chains, per-language overlay tables, label resolution | +| `graphdb_query` | Query language — parses and executes graph queries; snapshot-semantics sessions | +| `graphdb_mgr` | Primary coordinator — routes operations across the other specialized workers | --- @@ -232,26 +232,26 @@ Priority order — each step applies only to attributes not yet resolved by a hi ./rebar3 eunit --app=graphdb && ./rebar3 ct ``` -| Suite | Type | Tests | Coverage | -| ------------------------- | ----- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `graphdb_bootstrap_tests` | EUnit | 61 | Term parsing, validation, record conversion, nref macro consistency | -| `graphdb_class_tests` | EUnit | 13 | `is_valid_parent_kind/1`, `collect_qc_nrefs/2` | -| `graphdb_instance_tests` | EUnit | 13 | `find_avp_value/2`, composition-firing helpers (`summarize/1` etc.) | -| `graphdb_language_tests` | EUnit | 9 | Dialect-chain building, label-resolution helpers | -| `graphdb_mgr_tests` | EUnit | 9 | Direction validation, client-side arg checks | -| `graphdb_bootstrap_SUITE` | CT | 19 | Full bootstrap load, Mnesia tables, idempotency, error handling, Language subcategory nodes | -| `graphdb_mgr_SUITE` | CT | 28 | Bootstrap init, read ops, category guard, write stubs, cache audit/repair | -| `graphdb_attr_SUITE` | CT | 37 | Attribute create/lookup, seeding, relationship types, atomic reciprocal pair, literal sub-groups, `attribute_type`/`instantiable` markers | -| `graphdb_class_SUITE` | CT | 49 | Class create, QC (qualifying characteristics), lookups, hierarchy, multi-inheritance, inheritance, templates, abstract classes | -| `graphdb_instance_SUITE` | CT | 101 | Instance create (incl. composition rule firing, propose-mode outcomes, `{Min,Max}` multiplicity, and connection firing — resolver-driven mandatory/auto/propose, target validation), relationships (incl. arc validation, per-arc AVPs — attribute-value pairs), lookups, hierarchy, four-level inheritance, multi-class membership | -| `graphdb_language_SUITE` | CT | 27 | Multilingual overlay: language/dialect registration, per-language overlay tables, label resolution, translation hooks | -| `graphdb_query_SUITE` | CT | 43 | Query language: parse/execute, snapshot-semantics sessions, `#cont_path{}` resume, path finding | -| `graphdb_rules_SUITE` | CT | 71 | Rule meta-ontology seeding (incl. `reciprocal_nref` literal), composition/connection rule create/retrieve (incl. reciprocal param), validation catalog (incl. `{Min,Max}` multiplicity range), `effective_rules_for_class/2` taxonomy walk, `effective_connection_rules/2`, composition firing engine, propose mode | -| `graphdb_nref_SUITE` | CT | 6 | Switchable node-nref allocation facade; permanent/runtime phase flip | -| `graphdb_nrefs_SUITE` | CT | 2 | `graphdb_nrefs:verify/0` bootstrap nref-macro consistency check | -| `rel_id_server_SUITE` | CT | 7 | Relationship-row ID allocator (`get_id/0`, `get_id_pair/0`) | -| `dictionary_server_SUITE` | CT | 7 | `dictionary_server` gen_server behaviour | -| `term_server_SUITE` | CT | 7 | `term_server` gen_server behaviour | +| Suite | Type | Tests | Coverage | +| ------------------------- | ----- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `graphdb_bootstrap_tests` | EUnit | 61 | Term parsing, validation, record conversion, nref macro consistency | +| `graphdb_class_tests` | EUnit | 13 | `is_valid_parent_kind/1`, `collect_qc_nrefs/2` | +| `graphdb_instance_tests` | EUnit | 13 | `find_avp_value/2`, composition-firing helpers (`summarize/1` etc.) | +| `graphdb_language_tests` | EUnit | 9 | Dialect-chain building, label-resolution helpers | +| `graphdb_mgr_tests` | EUnit | 9 | Direction validation, client-side arg checks | +| `graphdb_bootstrap_SUITE` | CT | 19 | Full bootstrap load, Mnesia tables, idempotency, error handling, Language subcategory nodes | +| `graphdb_mgr_SUITE` | CT | 28 | Bootstrap init, read ops, category guard, write stubs, cache audit/repair | +| `graphdb_attr_SUITE` | CT | 37 | Attribute create/lookup, seeding, relationship types, atomic reciprocal pair, literal sub-groups, `attribute_type`/`instantiable` markers | +| `graphdb_class_SUITE` | CT | 49 | Class create, QC (qualifying characteristics), lookups, hierarchy, multi-inheritance, inheritance, templates, abstract classes | +| `graphdb_instance_SUITE` | CT | 106 | Instance create (incl. composition rule firing, propose-mode outcomes, `{Min,Max}` multiplicity, connection firing — resolver-driven mandatory/auto/propose, target validation — and horizontal conflict precedence via `create_instance/5`), relationships (incl. arc validation, per-arc AVPs — attribute-value pairs), lookups, hierarchy, four-level inheritance, multi-class membership | +| `graphdb_language_SUITE` | CT | 27 | Multilingual overlay: language/dialect registration, per-language overlay tables, label resolution, translation hooks | +| `graphdb_query_SUITE` | CT | 43 | Query language: parse/execute, snapshot-semantics sessions, `#cont_path{}` resume, path finding | +| `graphdb_rules_SUITE` | CT | 80 | Rule meta-ontology seeding (incl. `reciprocal_nref` literal), composition/connection rule create/retrieve (incl. reciprocal param), validation catalog (incl. `{Min,Max}` multiplicity range), `effective_rules_for_class/2` taxonomy walk, `effective_connection_rules/2`, composition firing engine, propose mode, connection firing, horizontal conflict resolution | +| `graphdb_nref_SUITE` | CT | 6 | Switchable node-nref allocation facade; permanent/runtime phase flip | +| `graphdb_nrefs_SUITE` | CT | 2 | `graphdb_nrefs:verify/0` bootstrap nref-macro consistency check | +| `rel_id_server_SUITE` | CT | 7 | Relationship-row ID allocator (`get_id/0`, `get_id_pair/0`) | +| `dictionary_server_SUITE` | CT | 7 | `dictionary_server` gen_server behaviour | +| `term_server_SUITE` | CT | 7 | `term_server` gen_server behaviour | Each CT test case runs in an isolated Mnesia database with a fresh nref allocator in a private temp directory. diff --git a/TASKS.md b/TASKS.md index 40a2dae..4ae411c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -32,16 +32,29 @@ consumes the rule data: conflict resolution, the interactive instantiation modes, and reactive learning. The durable design contract is `docs/designs/f4-graphdb-rules-design.md`. -### Conflict precedence +### Conflict precedence — IMPLEMENTED (F4 B5) When a class and its taxonomy ancestors each attach a rule that touches -the same component type or connection, the effective-rules gather -currently returns them additively, nearest-first, and resolves nothing. -Decide and implement horizontal conflict resolution: when two rules at -different levels genuinely conflict, which wins — does a nearer rule -shadow a farther one, or do they compose — and what the precedence order -is. This is the last outstanding piece of the firing engine's core; the -division is sketched in `docs/designs/f4-graphdb-rules-design.md` §11. +the same component type or connection, the effective-rules gather returns +them additively, nearest-first, and resolves nothing. Horizontal +conflict resolution is now applied at firing time by a **conflict +resolver** threaded through `create_instance/5`: the nearest-level member +of each conflict group wins by mode priority (mandatory > auto > +propose), surviving Min is the winner's and Max is the greatest across +winner + dropped losers, and a loser is demoted to `propose` only when it +and the winner both carry a non-default template. The default policy is +`graphdb_rules:default_conflict_resolver/0` (injected by `/3` and `/4`); +callers can override it via `/5`. Design +`docs/designs/f4-phase-b5-conflict-precedence-design.md`; the division is +also sketched in `docs/designs/f4-graphdb-rules-design.md` §11. + +**B5 follow-up — equidistant-diamond precedence.** The nearest-level +resolution assumes a distinct owning class per taxonomic distance (a +linear ancestor chain). An equidistant multi-parent diamond — two +parents at the same taxonomic distance, each attaching a conflicting +rule on the same child — resolves by `graphdb_class:ancestors/1` BFS +order rather than by mode-priority arbitration across the equidistant +parents. Revisit if equidistant-diamond ontologies become common. ### Instantiation engine — guided and automatic modes diff --git a/apps/graphdb/CLAUDE.md b/apps/graphdb/CLAUDE.md index c199827..5ae42c7 100644 --- a/apps/graphdb/CLAUDE.md +++ b/apps/graphdb/CLAUDE.md @@ -18,7 +18,7 @@ SPDX-License-Identifier: GPL-2.0-or-later | `graphdb_nref.erl` | Switchable node-nref allocation facade gen_server (first child; permanent during init) | | `graphdb_bootstrap.erl` | Bootstrap file loader + Mnesia schema creator (implemented) | | `graphdb_mgr.erl` | Primary coordinator gen_server (implemented — bootstrap init, read API, category guard) | -| `graphdb_rules.erl` | Graph rules gen_server (implemented — F4 Phase A+B1+B2+B3+B4: rule meta-ontology, create/retrieve, taxonomy walk, composition firing, propose mode, connection firing) | +| `graphdb_rules.erl` | Graph rules gen_server (implemented — F4 Phase A+B1+B2+B3+B4+B5: rule meta-ontology, create/retrieve, taxonomy walk, composition firing, propose mode, connection firing, conflict precedence) | | `graphdb_attr.erl` | Attribute library gen_server (implemented) | | `graphdb_class.erl` | Taxonomic hierarchy gen_server (implemented) | | `graphdb_instance.erl` | Instance/compositional hierarchy gen_server (implemented) | @@ -252,16 +252,16 @@ Manages the "is a" hierarchy of class nodes in the ontology. Creates and manages instance nodes in the project (instance space). -- `create_instance/3,4` (name, class_nref, compositional_parent_nref [, resolver]) — atomically writes the node record AND the instance→class membership relationship pair (arc labels nref=29 and nref=30), then fires composition rules (F4 B2). Returns `{ok, Nref, Report}` on success or `{error, Reason, Report}` on rule-firing failure; pre-plan validation errors (unknown class, non-instantiable class, etc.) return `{error, Reason}` (2-tuple). Rejects a class marked non-instantiable with `{error, {class_not_instantiable, ClassNref}}` (L9). Propose-mode composition rules surface as `proposed` outcomes in the report (B3); nothing is materialised for them. `/4` threads a connection **resolver** (`fun((ConnContext) -> {connect, [Target]} | defer end`): the RESOLVE step fires effective ConnectionRules (F4 B4) — `mandatory` connections to existing targets land in the root transaction, `auto` post-commit, `defer`/`propose` are reported only; targets are validated (exists, instance, instance-of target_class-or-subclass). `/3` uses the built-in `report_only` (defer-all) resolver, so connection rules surface as `required`/`not_connected`/`proposed` outcomes and nothing is connected. +- `create_instance/3,4,5` (name, class_nref, compositional_parent_nref [, connection_resolver [, conflict_resolver]]) — atomically writes the node record AND the instance→class membership relationship pair (arc labels nref=29 and nref=30), then fires composition rules (F4 B2). Returns `{ok, Nref, Report}` on success or `{error, Reason, Report}` on rule-firing failure; pre-plan validation errors (unknown class, non-instantiable class, etc.) return `{error, Reason}` (2-tuple). Rejects a class marked non-instantiable with `{error, {class_not_instantiable, ClassNref}}` (L9). Propose-mode composition rules surface as `proposed` outcomes in the report (B3); nothing is materialised for them. `/4` threads a connection **resolver** (`fun((ConnContext) -> {connect, [Target]} | defer end`): the RESOLVE step fires effective ConnectionRules (F4 B4) — `mandatory` connections to existing targets land in the root transaction, `auto` post-commit, `defer`/`propose` are reported only; targets are validated (exists, instance, instance-of target_class-or-subclass). `/3` uses the built-in `report_only` (defer-all) connection resolver, so connection rules surface as `required`/`not_connected`/`proposed` outcomes and nothing is connected. `/5` threads a B5 **conflict resolver** (`fun((#{kind, rules, class_nref}) -> [Pair])`); `/3` and `/4` inject the built-in `graphdb_rules:default_conflict_resolver/0`, which shadows conflicting inherited rules (nearest-level winner by mode priority), merges multiplicity (nearest Min, greatest Max), and demotes both-real-template losers to `propose` (F4 B5). - `add_relationship/4` (source_nref, characterization_nref, target_nref, reciprocal_nref) — writes two directed rows atomically; IDs allocated via `get_nref()` - `add_class_membership/2` (instance_nref, class_nref) — adds a membership arc pair; also rejects a non-instantiable class target with `{error, {class_not_instantiable, ClassNref}}` (L9) - `get_instance/1`, `children/1`, `compositional_ancestors/1`, `resolve_value/2` -### `graphdb_rules` — Graph Rules (F4 Phase A + B1 + B2 + B3 + B4) +### `graphdb_rules` — Graph Rules (F4 Phase A + B1 + B2 + B3 + B4 + B5) Stores composition and connection rules as instances of a seeded rule -meta-ontology. Phases A, B1, B2, B3, and B4 are implemented; Phase B5 -(precedence) and Phases C–F are tracked in `TASKS.md`. +meta-ontology. Phases A, B1, B2, B3, B4, and B5 are implemented; Phases C–F +are tracked in `TASKS.md`. - `create_composition_rule/6,7,8` (scope, name, parent_class, child_class, mode, multiplicity [, template_nref] [, opts]); `multiplicity :: {Min, Max}` where `Min :: non_neg_integer()`, `Max :: pos_integer() | unbounded` (`unbounded` legal only as `Max`); `opts #{name_pattern => string()}` sets the naming pattern for auto-named child instances - `create_connection_rule/8,9` (scope, name, source_class, characterization, **reciprocal**, target_class, mode, multiplicity [, template_nref]); same `{Min, Max}` multiplicity shape. The `reciprocal` arg (B4-D3) is the reverse arc label, stored as a `reciprocal_nref` content AVP; it supersedes the Phase A `/7,/8` forms (no reciprocal) @@ -276,9 +276,21 @@ meta-ontology. Phases A, B1, B2, B3, and B4 are implemented; Phase B5 each paired with its deployment and a content spec `#{characterization, reciprocal, target_class}`. Consumed by the connection firing engine in `graphdb_instance`. -- `plan_composition_firing/2` (scope, class_nref) — pure-read; returns an - abstract plan tree (maps, no nrefs) consumed by `graphdb_instance` during - `create_instance/3` and reused by B3 propose mode. +- `plan_composition_firing/2,3` (scope, class_nref [, conflict_resolver]) — + pure-read; returns an abstract plan tree (maps, no nrefs) consumed by + `graphdb_instance` during `create_instance/3` and reused by B3 propose mode. + The `/3` form applies a B5 conflict resolver at each cascade level; `/2` is + the additive identity path (no resolution) that preserves the B1 read + contract. +- `default_conflict_resolver/0` (F4 B5) — builds the default conflict-resolver + closure (reading the seed nrefs once, in the caller's process). The closure + dispatches on a `#{kind, rules, class_nref}` context: composition conflicts + group by referenced child class, connection conflicts by characterization + + referenced target class; within a group the nearest-level member wins by mode + priority (mandatory > auto > propose), surviving Min is the winner's, Max is + the greatest across winner + dropped losers, and both-real-template losers are + demoted to `propose`. Deadlock-safe in either gen_server (touches only + in-memory `#node` AVPs, dirty `relationships` reads, and `graphdb_class`). - `rule_child_class/1`, `rule_child_name/4` - `seeded_nrefs/0` - The `applies_to` arc's `multiplicity` deployment AVP stores the `{Min, Max}` tuple. Creation firing mints `Min` children/connections; `Max` is the ceiling for a future interactive-creation session. Propose-mode rules surface `Min` `proposed` outcomes, each carrying `max => Max` (B-prep / `docs/designs/f4-bprep-multiplicity-range-design.md`). @@ -350,13 +362,13 @@ The following callbacks in `graphdb.erl` return `ok` (no-op stubs correct for cu There are no remaining empty gen_server stubs. `graphdb_bootstrap`, `graphdb_mgr`, `graphdb_attr`, `graphdb_class`, `graphdb_instance`, `graphdb_language` (M6), `graphdb_query` (F3), and `graphdb_rules` -(F4 Phases A + B1 + B2 + B3 + B4) are all implemented. The `graphdb_rules` -firing engine (Phase B5 precedence + Phases C–F) remains, tracked in `TASKS.md`. +(F4 Phases A + B1 + B2 + B3 + B4 + B5) are all implemented. The `graphdb_rules` +firing engine Phases C–F remain, tracked in `TASKS.md`. ## Key Design Notes - `graphdb_sup:start_link/0` takes no args, matching every supervisor in the umbrella. It is called from `graphdb:start/2` after `graphdb_nref:set_permanent_phase/0` arms the permanent-tier allocator. `graphdb:start/2` then calls `graphdb_nref:set_runtime_phase/0` after `start_link` returns. -- `graphdb_bootstrap`, `graphdb_mgr` (startup + read API), `graphdb_attr`, `graphdb_class`, `graphdb_instance`, `graphdb_language`, `graphdb_query`, and `graphdb_rules` (F4 A+B1+B2+B3+B4) are implemented. Remaining work is in `TASKS.md` at the project root. +- `graphdb_bootstrap`, `graphdb_mgr` (startup + read API), `graphdb_attr`, `graphdb_class`, `graphdb_instance`, `graphdb_language`, `graphdb_query`, and `graphdb_rules` (F4 A+B1+B2+B3+B4+B5) are implemented. Remaining work is in `TASKS.md` at the project root. - Consult `../../docs/TheKnowledgeNetwork.md` for the full model spec before implementing ## Compile @@ -373,5 +385,5 @@ erlc apps/graphdb/src/graphdb_sup.erl apps/graphdb/src/graphdb.erl `graphdb_bootstrap.erl` is implemented; `graphdb_mgr`, `graphdb_attr`, `graphdb_class`, `graphdb_instance`, `graphdb_language`, `graphdb_query`, -and `graphdb_rules` (F4 A+B1+B2+B3+B4) are implemented. Outstanding work -(rules engine Phase B5 + Phases C–F, etc.) is in `TASKS.md` at the project root. +and `graphdb_rules` (F4 A+B1+B2+B3+B4+B5) are implemented. Outstanding work +(rules engine Phases C–F, etc.) is in `TASKS.md` at the project root. diff --git a/apps/graphdb/src/graphdb_instance.erl b/apps/graphdb/src/graphdb_instance.erl index df4f96b..94231bc 100644 --- a/apps/graphdb/src/graphdb_instance.erl +++ b/apps/graphdb/src/graphdb_instance.erl @@ -120,6 +120,7 @@ %% Creators create_instance/3, create_instance/4, + create_instance/5, add_relationship/4, add_relationship/5, add_relationship/6, @@ -185,17 +186,32 @@ create_instance(Name, ClassNref, ParentNref) -> create_instance(Name, ClassNref, ParentNref, fun report_only/1). %%----------------------------------------------------------------------------- -%% create_instance(Name, ClassNref, ParentNref, Resolver) -> +%% create_instance(Name, ClassNref, ParentNref, ConnResolver) -> %% {ok, Nref, report()} | {error, Reason, report()} | {error, Reason} %% -%% As /3, but threads a connection Resolver. /3 uses the built-in +%% As /3, but threads a connection ConnResolver. /3 uses the built-in %% report_only resolver (defer-all): every connection rule surfaces as a report -%% outcome and nothing is connected. +%% outcome and nothing is connected. /4 supplies the built-in default +%% conflict resolver. %%----------------------------------------------------------------------------- -create_instance(Name, ClassNref, ParentNref, Resolver) - when is_function(Resolver, 1) -> +create_instance(Name, ClassNref, ParentNref, ConnResolver) + when is_function(ConnResolver, 1) -> + create_instance(Name, ClassNref, ParentNref, ConnResolver, + graphdb_rules:default_conflict_resolver()). + +%%----------------------------------------------------------------------------- +%% create_instance(Name, ClassNref, ParentNref, ConnResolver, ConflictResolver) +%% -> {ok, Nref, report()} | {error, Reason, report()} | {error, Reason} +%% +%% As /4, but also threads a ConflictResolver. ConflictResolver is resolved +%% in the CALLER's process (where seeded_nrefs/0 is safe) and applied per +%% cascade level for composition rules and per plan node for connection rules. +%%----------------------------------------------------------------------------- +create_instance(Name, ClassNref, ParentNref, ConnResolver, ConflictResolver) + when is_function(ConnResolver, 1), is_function(ConflictResolver, 1) -> gen_server:call(?MODULE, - {create_instance, Name, ClassNref, ParentNref, Resolver}). + {create_instance, Name, ClassNref, ParentNref, ConnResolver, + ConflictResolver}). %% report_only(ConnContext) -> defer (the built-in /3 resolver) report_only(_Ctx) -> defer. @@ -372,9 +388,11 @@ init([]) -> %%----------------------------------------------------------------------------- %% handle_call/3 -- Creators %%----------------------------------------------------------------------------- -handle_call({create_instance, Name, ClassNref, ParentNref, Resolver}, _From, +handle_call({create_instance, Name, ClassNref, ParentNref, Resolver, + ConflictResolver}, _From, #state{instantiable_nref = InstAttr} = State) -> Ctx = #{inst_attr => InstAttr, on_path => [], resolver => Resolver, + conflict_resolver => ConflictResolver, root_parent => ParentNref, root_source => undefined}, {reply, do_create_instance(Name, ClassNref, ParentNref, Ctx), State}; @@ -495,7 +513,8 @@ do_create_instance(Name, ClassNref, ParentNref, Ctx) -> %% fires auto children best-effort post-commit. %%----------------------------------------------------------------------------- fire_create(Name, ClassNref, ParentNref, Ctx) -> - case graphdb_rules:plan_composition_firing(?RULE_SCOPE, ClassNref) of + case graphdb_rules:plan_composition_firing(?RULE_SCOPE, ClassNref, + maps:get(conflict_resolver, Ctx)) of {ok, PlanTree} -> case execute(Name, ClassNref, ParentNref, Ctx, PlanTree) of {ok, RootNref, MandOutcomes, InstPlan, AutoConnPlan} -> @@ -602,8 +621,11 @@ flatten_plan(#{nref := N, class := C, mandatory_children := Kids}) -> resolve_nodes([], _Ctx, {Rows, Auto, Rep}) -> {ok, Rows, Auto, Rep}; resolve_nodes([{SourceNref, Class} | Rest], Ctx, Acc) -> - {ok, ConnRules} = + {ok, ConnRules0} = graphdb_rules:effective_connection_rules(?RULE_SCOPE, Class), + ConflictResolver = maps:get(conflict_resolver, Ctx), + ConnRules = ConflictResolver(#{kind => connection, rules => ConnRules0, + class_nref => Class}), case resolve_rules(ConnRules, SourceNref, Ctx, Acc) of {ok, Acc1} -> resolve_nodes(Rest, Ctx, Acc1); {error, _, _} = Err -> Err diff --git a/apps/graphdb/src/graphdb_rules.erl b/apps/graphdb/src/graphdb_rules.erl index e1b5a63..2785d7f 100644 --- a/apps/graphdb/src/graphdb_rules.erl +++ b/apps/graphdb/src/graphdb_rules.erl @@ -102,6 +102,8 @@ effective_connection_rules/2, list_rules/1, plan_composition_firing/2, + plan_composition_firing/3, + default_conflict_resolver/0, rule_child_class/1, rule_child_name/4 ]). @@ -280,7 +282,8 @@ effective_rules_for_class(Scope, ClassNref) -> %% filtered to the ConnectionRule meta-class, each paired with its applies_to %% deployment and a ConnSpec decoded from the rule node's content AVPs. The %% connection-firing engine consumes this during create_instance. Additive -- a -%% rule reached from two ancestors appears twice (precedence is a later phase). +%% rule reached from two ancestors appears twice; horizontal precedence is +%% applied at firing time by the conflict resolver, not here. %% {project, _} -> {ok, []}. %%----------------------------------------------------------------------------- effective_connection_rules(Scope, ClassNref) -> @@ -322,6 +325,44 @@ list_rules(Scope) -> plan_composition_firing(Scope, ClassNref) -> gen_server:call(?MODULE, {plan_composition_firing, Scope, ClassNref}). +%%----------------------------------------------------------------------------- +%% plan_composition_firing(Scope, ClassNref, ConflictResolver) -> +%% {ok, PlanNode} | {error, Reason, #{plan_so_far, culprit}} +%% +%% As /2, but applies ConflictResolver to each cascade level's composition pairs +%% before planning. /2 is preserved as the additive (unresolved) public read. +%%----------------------------------------------------------------------------- +plan_composition_firing(Scope, ClassNref, ConflictResolver) -> + gen_server:call(?MODULE, + {plan_composition_firing, Scope, ClassNref, ConflictResolver}). + +%%----------------------------------------------------------------------------- +%% default_conflict_resolver() -> fun((ConflictContext) -> [Pair]) +%% +%% The built-in conflict resolver. Called in the CALLER's process (e.g. from +%% graphdb_instance:create_instance/3,4), where seeded_nrefs/0 is safe. Returns +%% ONE closure that bakes in the seed nrefs it needs and dispatches on the +%% context `kind'. The closure is deadlock-safe in either the graphdb_rules or +%% graphdb_instance process: it touches only in-memory #node AVPs, the +%% relationships table (dirty), and graphdb_class (a different gen_server). +%% +%% ConflictContext :: #{kind := composition | connection, +%% rules := [Pair], class_nref := integer()} +%% kind = composition -> Pair = {RuleNode, Deploy} +%% kind = connection -> Pair = {RuleNode, Deploy, ConnSpec} +%% +%% Bakes in the seed nrefs it needs (read ONCE here, in the caller's process) +%% and returns a closure dispatching on the context `kind': composition +%% conflicts are resolved by referenced child class, connection conflicts by +%% characterization + referenced target class. +%%----------------------------------------------------------------------------- +default_conflict_resolver() -> + {ok, Seeds} = seeded_nrefs(), + ChildAttr = maps:get(child_class_nref_attr, Seeds), + TplAttr = maps:get(template_nref_attr, Seeds), + AppliedBy = maps:get(applied_by, Seeds), + fun(Ctx) -> resolve_conflicts(Ctx, ChildAttr, TplAttr, AppliedBy) end. + %%----------------------------------------------------------------------------- %% rule_child_class(RuleNode :: #node{}) -> integer() | undefined %% @@ -499,10 +540,21 @@ handle_call({list_rules, environment}, _From, State) -> handle_call({list_rules, {project, _}}, _From, State) -> {reply, {ok, []}, State}; handle_call({plan_composition_firing, environment, ClassNref}, _From, State) -> - Reply = plan_node(ClassNref, root, undefined, undefined, [], State), + %% /2 is the additive, UNRESOLVED public read contract. It must stay + %% identity forever — do NOT route it through default_conflict_resolver/0, + %% which is the precedence algorithm (used only by the /3 firing path). + Identity = fun(#{rules := R}) -> R end, + Reply = plan_node(ClassNref, root, undefined, undefined, [], State, Identity), {reply, Reply, State}; handle_call({plan_composition_firing, {project, _}, ClassNref}, _From, State) -> {reply, {ok, leaf_plan(ClassNref, root, undefined, undefined)}, State}; +handle_call({plan_composition_firing, environment, ClassNref, Resolver}, + _From, State) -> + Reply = plan_node(ClassNref, root, undefined, undefined, [], State, Resolver), + {reply, Reply, State}; +handle_call({plan_composition_firing, {project, _}, ClassNref, _Resolver}, + _From, State) -> + {reply, {ok, leaf_plan(ClassNref, root, undefined, undefined)}, State}; handle_call(Request, From, State) -> ?UEM(handle_call, {Request, From, State}), {noreply, State}. @@ -935,16 +987,19 @@ leaf_plan(ClassNref, Rule, Deploy, Name) -> #{class => ClassNref, name => Name, rule => Rule, deploy => Deploy, mandatory_children => [], auto_rules => [], propose_rules => []}. -%% plan_node(ClassNref, Rule, Deploy, Name, OnPath, State) +%% plan_node(ClassNref, Rule, Deploy, Name, OnPath, State, Resolver) %% -> {ok, PlanNode} | {error, Reason, #{plan_so_far, culprit}} %% Recursively expands the mandatory cascade for ClassNref. OnPath is the %% class path root->here (cycle guard). Rule/Deploy describe the %% composition rule that mandated this node (`root`/`undefined` for the -%% requested instance). -plan_node(ClassNref, Rule, Deploy, Name, OnPath, State) -> +%% requested instance). Resolver is the conflict resolver, applied to this +%% level's composition pairs before planning. +plan_node(ClassNref, Rule, Deploy, Name, OnPath, State, Resolver) -> OnPath1 = [ClassNref | OnPath], - CompRules = composition_pairs(ClassNref, State), - plan_rules(CompRules, OnPath1, State, + CompRules0 = composition_pairs(ClassNref, State), + CompRules = Resolver(#{kind => composition, rules => CompRules0, + class_nref => ClassNref}), + plan_rules(CompRules, OnPath1, State, Resolver, leaf_plan(ClassNref, Rule, Deploy, Name)). %% composition_pairs(ClassNref, State) -> [{#node{}, Deployment}] @@ -982,33 +1037,211 @@ connection_spec(RuleNode, State) -> target_class => content_avp_value(RuleNode, State#state.target_class_nref_attr)}. -%% plan_rules(Pairs, OnPath1, State, Acc) -> {ok, PlanNode} | {error, R, Failure} +%%--------------------------------------------------------------------- +%% Conflict resolution (default resolver body) +%%--------------------------------------------------------------------- +%% resolve_conflicts(Ctx, ChildAttr, TplAttr, AppliedBy) -> [Pair] +%% Ctx = #{kind, rules, class_nref}. Pure over the seed nrefs + graphdb_class + +%% the relationships table; no graphdb_rules gen_server call (deadlock-safe in +%% either process). + +resolve_conflicts(#{kind := composition, rules := Pairs}, ChildAttr, TplAttr, + AppliedBy) -> + Items = [comp_item(P, ChildAttr, TplAttr, AppliedBy) || P <- Pairs], + Groups = assign_groups(Items, composition), + lists:flatmap(fun(G) -> resolve_group(G, composition) end, Groups); +resolve_conflicts(#{kind := connection, rules := Specs}, _ChildAttr, TplAttr, + AppliedBy) -> + Items = [conn_item(S, TplAttr, AppliedBy) || S <- Specs], + Groups = assign_groups(Items, connection), + lists:flatmap(fun(G) -> resolve_group(G, connection) end, Groups). + +%% comp_item({RuleNode, Deploy}, ChildAttr, TplAttr, AppliedBy) -> item() +%% item() = #{pair, ref, char, mode, min, max, owner, real_tpl} +%% The `mode' field feeds only pick_winner/1. An absent mode defaults to +%% `undefined' (priority 0) so a mode-less deployment yields to real rules +%% rather than winning its group -- matching plan_rules/5's own absent-mode +%% default. Rule creation always writes mode, so this is a defensive path. +comp_item({RuleNode, Deploy} = Pair, ChildAttr, TplAttr, AppliedBy) -> + {Min, Max} = maps:get(multiplicity, Deploy, {1, 1}), + Owner = owning_class(RuleNode, AppliedBy), + #{pair => Pair, + ref => content_avp_value(RuleNode, ChildAttr), + char => undefined, + mode => maps:get(mode, Deploy, undefined), + min => Min, + max => Max, + owner => Owner, + real_tpl => real_template(RuleNode, TplAttr, Owner)}. + +%% conn_item({Rule, Deploy, Spec}, TplAttr, AppliedBy) -> item() +%% target_class and characterization come from the connection Spec (no child +%% attr needed); real_tpl re-derives the owning (source) class via applied_by. +conn_item({Rule, Deploy, Spec} = Pair, TplAttr, AppliedBy) -> + {Min, Max} = maps:get(multiplicity, Deploy, {1, 1}), + Owner = owning_class(Rule, AppliedBy), + #{pair => Pair, + ref => maps:get(target_class, Spec), + char => maps:get(characterization, Spec), + mode => maps:get(mode, Deploy, undefined), + min => Min, + max => Max, + owner => Owner, + real_tpl => real_template(Rule, TplAttr, Owner)}. + +%% real_template(RuleNode, TplAttr, OwningClass) -> boolean() +%% True iff the rule carries a content template_nref AVP whose value differs +%% from its owning class's default template. A rule created without an +%% explicit template stores no template_nref content AVP (see +%% optional_template_avp/2), so the undefined clause covers default-template +%% rules; the TplNref =:= Default branch covers an explicit AVP that happens to +%% equal the owner's default. Either way default-template rules -> false. +real_template(RuleNode, TplAttr, OwningClass) -> + case content_avp_value(RuleNode, TplAttr) of + undefined -> + false; + TplNref -> + case graphdb_class:default_template(OwningClass) of + {ok, Default} -> TplNref =/= Default; + _ -> true + end + end. + +%% owning_class(RuleNode, AppliedBy) -> integer() | undefined +%% Re-derives the rule's owning class from its applied_by arc (source=Rule, +%% char=applied_by -> target=owning class). See do_create_rule/7. +owning_class(#node{nref = RuleNref}, AppliedBy) -> + Arcs = mnesia:dirty_index_read(relationships, RuleNref, + #relationship.source_nref), + case [A#relationship.target_nref || A <- Arcs, + A#relationship.characterization =:= AppliedBy] of + [Owner | _] -> Owner; + [] -> undefined + end. + +%% assign_groups(Items, Kind) -> [[item()]] +%% Walks nearest-first; each item joins the first group whose head (anchor = +%% nearest member) it matches, else starts a new group. Groups preserve +%% nearest-first member order; group list preserves creation order. +assign_groups(Items, Kind) -> + lists:foldl(fun(Item, Groups) -> + case find_group(Item, Groups, Kind, 1) of + {Idx, _G} -> append_to_group(Idx, Item, Groups); + none -> Groups ++ [[Item]] + end + end, [], Items). + +find_group(_Item, [], _Kind, _Idx) -> + none; +find_group(Item, [G | Rest], Kind, Idx) -> + case same_conflict(Kind, hd(G), Item) of + true -> {Idx, G}; + false -> find_group(Item, Rest, Kind, Idx + 1) + end. + +append_to_group(Idx, Item, Groups) -> + {Before, [G | After]} = lists:split(Idx - 1, Groups), + Before ++ [G ++ [Item]] ++ After. + +%% same_conflict(Kind, Anchor, Item) -> boolean() +%% The anchor (nearest member) must be same-or-descendant of the candidate. +%% class_in_ancestry(FartherRef, NearerRef): ANCESTOR first, DESCENDANT second +%% (arg-order hazard -- the connection path has a canary for the same call). FartherRef = +%% candidate's ref, NearerRef = anchor's ref. +same_conflict(composition, Anchor, Item) -> + graphdb_class:class_in_ancestry(maps:get(ref, Item), maps:get(ref, Anchor)); +same_conflict(connection, Anchor, Item) -> + maps:get(char, Anchor) =:= maps:get(char, Item) + andalso graphdb_class:class_in_ancestry(maps:get(ref, Item), + maps:get(ref, Anchor)). + +%% resolve_group(Group, Kind) -> [Pair] +%% Winner = highest mode-priority among the nearest-level prefix; losers are +%% dropped (their Max merges) unless both winner and loser are real-templated, +%% in which case the loser is re-emitted as an independent propose. +resolve_group(Group, Kind) -> + OwnerHd = maps:get(owner, hd(Group)), + %% Nearest-level prefix assumes a distinct owning class per taxonomic + %% distance (linear chain). An equidistant multi-parent diamond would + %% resolve by graphdb_class:ancestors/1 ordering -- see the + %% equidistant-diamond precedence follow-up in TASKS.md. + NearestLevel = lists:takewhile( + fun(I) -> maps:get(owner, I) =:= OwnerHd end, Group), + Winner = pick_winner(NearestLevel), + Losers = Group -- [Winner], + {Demoted, Dropped} = lists:partition( + fun(L) -> maps:get(real_tpl, Winner) andalso maps:get(real_tpl, L) end, + Losers), + MergedMax = lists:foldl( + fun(I, Acc) -> merge_max(Acc, maps:get(max, I)) end, + maps:get(max, Winner), Dropped), + WinnerOut = rebuild(Winner, Kind, {maps:get(min, Winner), MergedMax}, + keep_mode), + DemotedOuts = [ rebuild(D, Kind, {maps:get(min, D), maps:get(max, D)}, + propose) || D <- Demoted ], + [WinnerOut | DemotedOuts]. + +%% pick_winner([item()]) -> item() +%% Highest mode priority; ties keep the earliest (arc order). +pick_winner([H | T]) -> + lists:foldl(fun(C, Best) -> + case priority(maps:get(mode, C)) > priority(maps:get(mode, Best)) of + true -> C; + false -> Best + end + end, H, T). + +priority(mandatory) -> 3; +priority(auto) -> 2; +priority(propose) -> 1; +priority(_) -> 0. + +%% merge_max(MaxA, MaxB) -> Max (unbounded dominates) +merge_max(unbounded, _) -> unbounded; +merge_max(_, unbounded) -> unbounded; +merge_max(A, B) -> max(A, B). + +%% rebuild(item(), Kind, {Min, Max}, keep_mode | propose) -> Pair +rebuild(Item, composition, Mult, ModeSpec) -> + {RuleNode, Deploy} = maps:get(pair, Item), + {RuleNode, set_mode(Deploy#{multiplicity => Mult}, ModeSpec)}; +rebuild(Item, connection, Mult, ModeSpec) -> + {Rule, Deploy, Spec} = maps:get(pair, Item), + {Rule, set_mode(Deploy#{multiplicity => Mult}, ModeSpec), Spec}. + +set_mode(Deploy, keep_mode) -> Deploy; +set_mode(Deploy, propose) -> Deploy#{mode => propose}. + +%% plan_rules(Pairs, OnPath1, State, Resolver, Acc) +%% -> {ok, PlanNode} | {error, R, Failure} %% First-failure-aborts: a mandatory violation stops planning. -plan_rules([], _OnPath1, _State, Acc) -> +plan_rules([], _OnPath1, _State, _Resolver, Acc) -> {ok, Acc}; -plan_rules([{RuleNode, Deploy} | Rest], OnPath1, State, Acc) -> +plan_rules([{RuleNode, Deploy} | Rest], OnPath1, State, Resolver, Acc) -> case maps:get(mode, Deploy, undefined) of auto -> Autos = maps:get(auto_rules, Acc) ++ [{RuleNode, Deploy}], - plan_rules(Rest, OnPath1, State, Acc#{auto_rules => Autos}); + plan_rules(Rest, OnPath1, State, Resolver, + Acc#{auto_rules => Autos}); propose -> %% Accumulate (composition firing dropped these). Mirrors the `auto` clause; %% graphdb_instance:fire_propose/2 expands multiplicity post-commit %% and emits `proposed` outcomes. Unexpanded here, like auto_rules. Proposes = maps:get(propose_rules, Acc) ++ [{RuleNode, Deploy}], - plan_rules(Rest, OnPath1, State, Acc#{propose_rules => Proposes}); + plan_rules(Rest, OnPath1, State, Resolver, + Acc#{propose_rules => Proposes}); mandatory -> - case plan_mandatory(RuleNode, Deploy, OnPath1, State, Acc) of - {ok, Acc1} -> plan_rules(Rest, OnPath1, State, Acc1); + case plan_mandatory(RuleNode, Deploy, OnPath1, State, Resolver, Acc) of + {ok, Acc1} -> plan_rules(Rest, OnPath1, State, Resolver, Acc1); {error, _, _} = Err -> Err %% first-failure abort end; _ -> - plan_rules(Rest, OnPath1, State, Acc) + plan_rules(Rest, OnPath1, State, Resolver, Acc) end. -%% plan_mandatory(RuleNode, Deploy, OnPath1, State, Acc) +%% plan_mandatory(RuleNode, Deploy, OnPath1, State, Resolver, Acc) %% -> {ok, Acc'} | {error, Reason, #{plan_so_far, culprit}} -plan_mandatory(RuleNode, Deploy, OnPath1, State, Acc) -> +plan_mandatory(RuleNode, Deploy, OnPath1, State, Resolver, Acc) -> ChildClass = content_avp_value(RuleNode, State#state.child_class_nref_attr), case lists:member(ChildClass, OnPath1) of @@ -1019,7 +1252,7 @@ plan_mandatory(RuleNode, Deploy, OnPath1, State, Acc) -> case graphdb_class:is_instantiable(ChildClass) of true -> expand_children(RuleNode, Deploy, ChildClass, Min, 1, - OnPath1, State, Acc); + OnPath1, State, Resolver, Acc); false -> fail({class_not_instantiable, ChildClass}, RuleNode, Acc); @@ -1033,18 +1266,20 @@ plan_mandatory(RuleNode, Deploy, OnPath1, State, Acc) -> fail(Reason, CulpritRule, Acc) -> {error, Reason, #{plan_so_far => Acc, culprit => CulpritRule}}. -%% expand_children(RuleNode, Deploy, ChildClass, Mult, I, OnPath1, State, Acc) +%% expand_children(RuleNode, Deploy, ChildClass, Mult, I, OnPath1, State, +%% Resolver, Acc) %% -> {ok, Acc'} | {error, R, Failure} -expand_children(_RuleNode, _Deploy, _ChildClass, Mult, I, _OnPath1, _State, Acc) - when I > Mult -> +expand_children(_RuleNode, _Deploy, _ChildClass, Mult, I, _OnPath1, _State, + _Resolver, Acc) when I > Mult -> {ok, Acc}; -expand_children(RuleNode, Deploy, ChildClass, Mult, I, OnPath1, State, Acc) -> +expand_children(RuleNode, Deploy, ChildClass, Mult, I, OnPath1, State, Resolver, + Acc) -> Name = resolve_child_name(RuleNode, ChildClass, I, Mult, State), - case plan_node(ChildClass, RuleNode, Deploy, Name, OnPath1, State) of + case plan_node(ChildClass, RuleNode, Deploy, Name, OnPath1, State, Resolver) of {ok, ChildPlan} -> Kids = maps:get(mandatory_children, Acc) ++ [ChildPlan], expand_children(RuleNode, Deploy, ChildClass, Mult, I + 1, OnPath1, - State, Acc#{mandatory_children => Kids}); + State, Resolver, Acc#{mandatory_children => Kids}); {error, R, Failure} -> %% Nested failure: rewrite plan_so_far to THIS level's Acc (parent %% with completed siblings; failing branch dropped), keep the leaf diff --git a/apps/graphdb/test/graphdb_instance_SUITE.erl b/apps/graphdb/test/graphdb_instance_SUITE.erl index ec0fe7e..d1ccc5a 100644 --- a/apps/graphdb/test/graphdb_instance_SUITE.erl +++ b/apps/graphdb/test/graphdb_instance_SUITE.erl @@ -172,7 +172,14 @@ firing_conn_subclass_target_accepted/1, firing_conn_missing_target_fails/1, firing_conn_non_instance_target_fails/1, - firing_conn_resolver_avps_stamped/1 + firing_conn_resolver_avps_stamped/1, + %% B5 plumbing + b5_create_instance_5_accepts_resolvers/1, + b5_default_resolver_single_rule_unchanged/1, + %% B5 end-to-end firing + b5_firing_same_level_mode_priority/1, + b5_firing_cross_level_shadow/1, + b5_custom_resolver_pure_additive/1 ]). @@ -303,7 +310,12 @@ groups() -> firing_conn_subclass_target_accepted, firing_conn_missing_target_fails, firing_conn_non_instance_target_fails, - firing_conn_resolver_avps_stamped + firing_conn_resolver_avps_stamped, + b5_create_instance_5_accepts_resolvers, + b5_default_resolver_single_rule_unchanged, + b5_firing_same_level_mode_priority, + b5_firing_cross_level_shadow, + b5_custom_resolver_pure_additive ]} ]. @@ -2140,3 +2152,89 @@ b4_conn_targets(Source, Char) -> [A#relationship.target_nref || A <- Arcs, A#relationship.kind =:= connection, A#relationship.characterization =:= Char]. + + +%%----------------------------------------------------------------------------- +%% B5 plumbing: create_instance/5 is accepted and, with the default resolver, +%% a single inherited mandatory rule still fires exactly as /3 (no regression). +%%----------------------------------------------------------------------------- +b5_create_instance_5_accepts_resolvers(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 1}), + Conn = fun(_Ctx) -> defer end, + Conflict = graphdb_rules:default_conflict_resolver(), + {ok, Root, Report} = + graphdb_instance:create_instance("car", Vehicle, 5, Conn, Conflict), + {ok, Kids} = graphdb_instance:children(Root), + ?assertEqual(1, length(Kids)), + ?assertEqual(#{fired => 1, failed => 0, not_attempted => 0, proposed => 0, + connected => 0, required => 0, not_connected => 0}, + graphdb_instance:summarize(Report)). + +%%----------------------------------------------------------------------------- +%% B5 plumbing: /3 (built-in default conflict resolver) is unchanged for a +%% plain single-rule fire. +%%----------------------------------------------------------------------------- +b5_default_resolver_single_rule_unchanged(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 1}), + {ok, Root, Report} = graphdb_instance:create_instance("car", Vehicle, 5), + {ok, Kids} = graphdb_instance:children(Root), + ?assertEqual(1, length(Kids)), + ?assertEqual(1, length(Report)). + +%%----------------------------------------------------------------------------- +%% Firing flip (B5-D2 at firing time): Cell mandates Nucleus (mandatory) and +%% proposes Nucleus. Under B5 only ONE Nucleus is minted (mandatory wins). +%%----------------------------------------------------------------------------- +b5_firing_same_level_mode_priority(_Config) -> + {ok, Cell} = graphdb_class:create_class("Cell", 3), + {ok, Nucleus} = graphdb_class:create_class("Nucleus", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CN-prop", Cell, Nucleus, propose, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CN-mand", Cell, Nucleus, mandatory, {1, 1}), + {ok, Root, Report} = graphdb_instance:create_instance("c1", Cell, 5), + {ok, Kids} = graphdb_instance:children(Root), + ?assertEqual(1, length(Kids)), %% exactly one Nucleus minted + #{fired := 1, proposed := 0} = + maps:with([fired, proposed], graphdb_instance:summarize(Report)). + +%%----------------------------------------------------------------------------- +%% Cross-level shadow at firing time: Car + Vehicle both mandate Engine -> one +%% Engine minted (not two). +%%----------------------------------------------------------------------------- +b5_firing_cross_level_shadow(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CE", Car, Engine, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 1}), + {ok, Root, _Report} = graphdb_instance:create_instance("car", Car, 5), + {ok, Kids} = graphdb_instance:children(Root), + ?assertEqual(1, length(Kids)). + +%%----------------------------------------------------------------------------- +%% Custom resolver overrides the seam: a pure-additive resolver makes Car + +%% Vehicle both fire (two Engines), proving the policy is caller-overridable. +%%----------------------------------------------------------------------------- +b5_custom_resolver_pure_additive(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CE", Car, Engine, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 1}), + Additive = fun(#{rules := R}) -> R end, + Conn = fun(_Ctx) -> defer end, + {ok, Root, _Report} = + graphdb_instance:create_instance("car", Car, 5, Conn, Additive), + {ok, Kids} = graphdb_instance:children(Root), + ?assertEqual(2, length(Kids)). %% additive: both fire diff --git a/apps/graphdb/test/graphdb_rules_SUITE.erl b/apps/graphdb/test/graphdb_rules_SUITE.erl index a320201..ede90cd 100644 --- a/apps/graphdb/test/graphdb_rules_SUITE.erl +++ b/apps/graphdb/test/graphdb_rules_SUITE.erl @@ -84,6 +84,21 @@ plan_project_scope_is_leaf/1 ]). +%%--------------------------------------------------------------------- +%% Conflict resolution test case exports (F4 B5) +%%--------------------------------------------------------------------- +-export([ + b5_comp_cross_level_shadow/1, + b5_comp_descendant_shadow/1, + b5_comp_additive_unrelated/1, + b5_comp_max_merge_unbounded/1, + b5_comp_same_level_mode_priority/1, + b5_comp_both_real_template_demote/1, + b5_comp_mixed_template_drop/1, + b5_conn_target_shadow/1, + b5_conn_additive_unrelated/1 +]). + %%--------------------------------------------------------------------- %% Test cases %%--------------------------------------------------------------------- @@ -167,7 +182,7 @@ all() -> [{group, seeding}, {group, composition}, {group, connection}, {group, validation}, {group, retrieval}, {group, scope}, {group, complex_scenarios}, {group, effective}, {group, cache_audit}, - {group, plan_firing}]. + {group, plan_firing}, {group, conflict_resolution}]. groups() -> [ @@ -261,6 +276,17 @@ groups() -> plan_propose_accumulated, plan_mixed_modes, plan_propose_at_mandatory_child + ]}, + {conflict_resolution, [], [ + b5_comp_cross_level_shadow, + b5_comp_descendant_shadow, + b5_comp_additive_unrelated, + b5_comp_max_merge_unbounded, + b5_comp_same_level_mode_priority, + b5_comp_both_real_template_demote, + b5_comp_mixed_template_drop, + b5_conn_target_shadow, + b5_conn_additive_unrelated ]} ]. @@ -1354,10 +1380,199 @@ verify_caches_passes_after_rule_creation(_Config) -> ?assertEqual(ok, graphdb_mgr:verify_caches()). +%%----------------------------------------------------------------------------- +%% Group: conflict_resolution (F4 B5 default composition resolver) +%%----------------------------------------------------------------------------- + +%%----------------------------------------------------------------------------- +%% Cross-level shadow: Car (nearest) and Vehicle (ancestor) both mandate Engine. +%% Resolved to ONE pair: nearest mode + nearest Min, greatest Max. +%%----------------------------------------------------------------------------- +b5_comp_cross_level_shadow(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CE", Car, Engine, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 3}), + {ok, [{_R, Dep}]} = resolve_comp(Car), + ?assertEqual(mandatory, maps:get(mode, Dep)), + ?assertEqual({1, 3}, maps:get(multiplicity, Dep)). + +%%----------------------------------------------------------------------------- +%% Descendant shadow (B5-D1): Car mandates ElectricMotor (is-a Engine); +%% Vehicle mandates Engine. ElectricMotor wins; Vehicle's Engine rule shadowed. +%%----------------------------------------------------------------------------- +b5_comp_descendant_shadow(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, EMotor} = graphdb_class:create_class("ElectricMotor", Engine), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CEM", Car, EMotor, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 1}), + {ok, [{R, _Dep}]} = resolve_comp(Car), + ?assertEqual(EMotor, graphdb_rules:rule_child_class(R)). + +%%----------------------------------------------------------------------------- +%% Additive: unrelated child classes both survive (two pairs). +%%----------------------------------------------------------------------------- +b5_comp_additive_unrelated(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, Radio} = graphdb_class:create_class("Radio", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CE", Car, Engine, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VR", Vehicle, Radio, mandatory, {1, 1}), + {ok, Pairs} = resolve_comp(Car), + ?assertEqual(2, length(Pairs)). + +%%----------------------------------------------------------------------------- +%% Greatest-Max merge across 3 levels including unbounded -> unbounded dominates. +%%----------------------------------------------------------------------------- +b5_comp_max_merge_unbounded(_Config) -> + {ok, A} = graphdb_class:create_class("A", 3), + {ok, B} = graphdb_class:create_class("B", A), + {ok, C} = graphdb_class:create_class("C", B), + {ok, Eng} = graphdb_class:create_class("Engine", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CE", C, Eng, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "BE", B, Eng, mandatory, {1, 2}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "AE", A, Eng, mandatory, {1, unbounded}), + {ok, [{_R, Dep}]} = resolve_comp(C), + ?assertEqual({1, unbounded}, maps:get(multiplicity, Dep)). + +%%----------------------------------------------------------------------------- +%% Same-level mode-priority tie (B5-D2): two rules on Cell, both child=Nucleus, +%% one mandatory one propose. mandatory wins; one pair survives. +%%----------------------------------------------------------------------------- +b5_comp_same_level_mode_priority(_Config) -> + {ok, Cell} = graphdb_class:create_class("Cell", 3), + {ok, Nucleus} = graphdb_class:create_class("Nucleus", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CN-prop", Cell, Nucleus, propose, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CN-mand", Cell, Nucleus, mandatory, {1, 1}), + {ok, [{_R, Dep}]} = resolve_comp(Cell), + ?assertEqual(mandatory, maps:get(mode, Dep)). + +%%----------------------------------------------------------------------------- +%% Both-real-template demote (B5-D4): Car@tplA auto Engine; Vehicle@tplA +%% mandatory Engine. Winner = Car's auto Engine (fires); loser re-emitted as +%% an independent propose keeping its own {1,2} range. +%% +%% Both rules carry TplA (Engine's default template). TplA differs from each +%% rule's OWN owning class's default (Car's and Vehicle's), so both count as +%% real templates per B5-D5. (TplB / Vehicle's own default deliberately NOT +%% used for the VE rule: it would equal Vehicle's own default -> not real -> +%% mixed-pair drop, defeating the test.) +%%----------------------------------------------------------------------------- +b5_comp_both_real_template_demote(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + %% real (non-default) template: borrow Engine's default template for both + %% rules (non-default relative to Car AND Vehicle). + {ok, TplA} = graphdb_class:default_template(Engine), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CE", Car, Engine, auto, {1, 1}, TplA), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 2}, TplA), + {ok, Pairs} = resolve_comp(Car), + ?assertEqual(2, length(Pairs)), + Modes = [maps:get(mode, D) || {_R, D} <- Pairs], + ?assertEqual([auto, propose], Modes), + %% the demoted propose keeps its OWN {1,2}, not merged + [{_, WinnerDep}, {_, PropDep}] = Pairs, + ?assertEqual({1, 1}, maps:get(multiplicity, WinnerDep)), + ?assertEqual({1, 2}, maps:get(multiplicity, PropDep)). + +%%----------------------------------------------------------------------------- +%% Mixed template drop (B5-D4): only the nearest carries a real template; the +%% ancestor uses its default. Loser dropped, greatest-Max merged, no propose. +%%----------------------------------------------------------------------------- +b5_comp_mixed_template_drop(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, TplA} = graphdb_class:default_template(Engine), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CE", Car, Engine, auto, {1, 1}, TplA), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 2}), %% default tpl + {ok, [{_R, Dep}]} = resolve_comp(Car), + ?assertEqual(auto, maps:get(mode, Dep)), + ?assertEqual({1, 2}, maps:get(multiplicity, Dep)). %% greatest Max merged + +%%----------------------------------------------------------------------------- +%% Connection target shadow (B5-D1): Car owns Garage (is-a Building); Vehicle +%% owns Building, same `owns' characterization. One winner -> Garage. +%%----------------------------------------------------------------------------- +b5_conn_target_shadow(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Building} = graphdb_class:create_class("Building", 3), + {ok, Garage} = graphdb_class:create_class("Garage", Building), + {Owns, Owned} = make_rel_pair("owns", "owned_by"), + {ok, _} = graphdb_rules:create_connection_rule( + environment, "CG", Car, Owns, Owned, Garage, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_connection_rule( + environment, "VB", Vehicle, Owns, Owned, Building, mandatory, {1, 1}), + {ok, [{_R, _Dep, Spec}]} = resolve_conn(Car), + ?assertEqual(Garage, maps:get(target_class, Spec)). + +%%----------------------------------------------------------------------------- +%% Connection additive (unrelated targets, same characterization): both survive. +%%----------------------------------------------------------------------------- +b5_conn_additive_unrelated(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Building} = graphdb_class:create_class("Building", 3), + {ok, Boat} = graphdb_class:create_class("Boat", 3), + {Owns, Owned} = make_rel_pair("owns", "owned_by"), + {ok, _} = graphdb_rules:create_connection_rule( + environment, "CB", Car, Owns, Owned, Boat, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_connection_rule( + environment, "VB", Vehicle, Owns, Owned, Building, mandatory, {1, 1}), + {ok, Pairs} = resolve_conn(Car), + ?assertEqual(2, length(Pairs)). + + %%============================================================================= %% Local test helpers %%============================================================================= +%% resolve_comp(ClassNref) -> {ok, [{#node{}, Deploy}]} +%% Drives the default conflict resolver over the composition rules effective for +%% ClassNref, exactly as plan_node would. +resolve_comp(ClassNref) -> + {ok, Effective} = graphdb_rules:effective_rules_for_class(environment, + ClassNref), + Pairs = [P || {_Level, LvlPairs} <- Effective, P <- LvlPairs, + is_composition_pair(P)], + Resolver = graphdb_rules:default_conflict_resolver(), + {ok, Resolver(#{kind => composition, rules => Pairs, class_nref => ClassNref})}. + +%% resolve_conn(ClassNref) -> {ok, [{#node{}, Deploy, Spec}]} +%% Drives the default conflict resolver over the connection rules effective for +%% ClassNref, exactly as the connection-firing path would. +resolve_conn(ClassNref) -> + {ok, Specs} = graphdb_rules:effective_connection_rules(environment, ClassNref), + Resolver = graphdb_rules:default_conflict_resolver(), + {ok, Resolver(#{kind => connection, rules => Specs, class_nref => ClassNref})}. + +%% is_composition_pair({RuleNode, _Deploy}) -> boolean() +%% A pair is composition iff its rule node is a CompositionRule instance. +is_composition_pair({#node{classes = Classes}, _Deploy}) -> + {ok, S} = graphdb_rules:seeded_nrefs(), + lists:member(maps:get(composition_rule, S), Classes). + %% find_avp(AVPs, AttrNref) -> {ok, Value} | not_found %% Searches an AVP list for an entry whose attribute key equals AttrNref; %% returns {ok, Value} on the first match, not_found if absent. diff --git a/docs/Architecture.md b/docs/Architecture.md index 1b88ff9..cb8b3de 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -15,29 +15,30 @@ SPDX-License-Identifier: GPL-2.0-or-later ## 1. Status -| Component | State | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Build | Compiles clean — zero warnings (Erlang/OTP, the Open Telecom Platform, version 28 / rebar3 3.27) | -| `nref` subsystem | Fully implemented; backed by DETS (Disk-based Erlang Term Storage); `set_floor/1` API | -| `dictionary_imp` | Implemented; not yet wired to `dictionary_server` / `term_server` | -| `graphdb_bootstrap` | Implemented — Mnesia schema, table creation, scaffold loader | -| `graphdb_mgr` | Implemented — bootstrap startup, read API, category guard, cache audit/repair. Write-side delegation pending. | -| `graphdb_attr` | Implemented — attribute library (name, literal, relationship attributes) | -| `graphdb_class` | Implemented — taxonomic hierarchy with multi-parent inheritance (BFS — breadth-first search — over a DAG, a directed acyclic graph); abstract (non-instantiable) classes via the `instantiable` marker | -| `graphdb_instance` | Implemented — compositional hierarchy + four-level inheritance with multi-class membership and ambiguity-detecting class resolver; refuses instantiation/membership of abstract classes; fires composition rules on `create_instance/3` and surfaces `proposed` outcomes for propose-mode rules; fires connection rules via a caller-supplied resolver on `create_instance/4` | -| `graphdb_rules` | Implemented — rule meta-ontology, applies_to attachment, scope-aware create/retrieve, taxonomy-walking effective-rules read, composition firing engine, propose mode, connection firing | -| `graphdb_language` | Implemented — multilingual overlay layer (label resolution, dialect chains, per-language Mnesia overlay tables) | -| `graphdb_query` | Implemented — query language with snapshot-semantics sessions and continuation-based bounded BFS | -| Tests | 476 passing (371 Common Test + 105 EUnit) | +| Component | State | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Build | Compiles clean — zero warnings (Erlang/OTP, the Open Telecom Platform, version 28 / rebar3 3.27) | +| `nref` subsystem | Fully implemented; backed by DETS (Disk-based Erlang Term Storage); `set_floor/1` API | +| `dictionary_imp` | Implemented; not yet wired to `dictionary_server` / `term_server` | +| `graphdb_bootstrap` | Implemented — Mnesia schema, table creation, scaffold loader | +| `graphdb_mgr` | Implemented — bootstrap startup, read API, category guard, cache audit/repair. Write-side delegation pending. | +| `graphdb_attr` | Implemented — attribute library (name, literal, relationship attributes) | +| `graphdb_class` | Implemented — taxonomic hierarchy with multi-parent inheritance (BFS — breadth-first search — over a DAG, a directed acyclic graph); abstract (non-instantiable) classes via the `instantiable` marker | +| `graphdb_instance` | Implemented — compositional hierarchy + four-level inheritance with multi-class membership and ambiguity-detecting class resolver; refuses instantiation/membership of abstract classes; fires composition rules on `create_instance/3` and surfaces `proposed` outcomes for propose-mode rules; fires connection rules via a caller-supplied resolver on `create_instance/4`; applies horizontal conflict precedence via a caller-overridable resolver on `create_instance/5` | +| `graphdb_rules` | Implemented — rule meta-ontology, applies_to attachment, scope-aware create/retrieve, taxonomy-walking effective-rules read, composition firing engine, propose mode, connection firing, horizontal conflict precedence | +| `graphdb_language` | Implemented — multilingual overlay layer (label resolution, dialect chains, per-language Mnesia overlay tables) | +| `graphdb_query` | Implemented — query language with snapshot-semantics sessions and continuation-based bounded BFS | +| Tests | 523 passing (418 Common Test + 105 EUnit) | The kernel is functional under multi-inheritance, multi-class- membership, and per-class template semantics. Multilingual label overlay (§10) and the query language (§11) are landed. The `graphdb_rules` data model (§12) is landed, along with the taxonomy-walk effective-rules read, the composition firing engine, -propose mode (`create_instance/3` surfaces `proposed` outcomes), and -connection firing. The later firing-engine work — conflict precedence, -the instantiation engine, and reactive learning — remains. +propose mode (`create_instance/3` surfaces `proposed` outcomes), +connection firing, and horizontal conflict precedence. The later +firing-engine work — the instantiation engine and reactive learning — +remains. --- @@ -235,7 +236,7 @@ graphdb (application — started after mnesia + nref) ├── graphdb_instance — compositional hierarchy + inheritance ├── graphdb_language — multilingual label overlay ├── graphdb_query — query language gen_server - └── graphdb_rules — rule meta-ontology + create/retrieve + composition firing + propose mode + connection firing + └── graphdb_rules — rule meta-ontology + create/retrieve + composition firing + propose mode + connection firing + conflict precedence dictionary (application — started alongside graphdb) └── dictionary_sup @@ -595,9 +596,9 @@ contract. `graphdb_rules` implements the rules data model and the firing engine: storage and retrieval, taxonomy-walking effective-rules reads, the -composition firing engine, propose mode, and connection firing. The -later firing-engine work — conflict precedence, the instantiation -engine, and reactive learning — remains, tracked in +composition firing engine, propose mode, connection firing, and +horizontal conflict precedence. The later firing-engine work — the +instantiation engine and reactive learning — remains, tracked in [`../TASKS.md`](../TASKS.md). Architectural shape: @@ -623,7 +624,7 @@ Architectural shape: a nearest-first, deployment-bearing gather of every rule attached to the class and its superclasses, grouped by attaching class. It resolves nothing — additive-vs-shadow is the firing engine's job (conflict - precedence, still outstanding). + precedence; see below). - **Composition firing.** `graphdb_instance:create_instance/3` calls `graphdb_rules:plan_composition_firing/2` to build an abstract plan tree, then executes it: `mandatory` rules fire inside the same transaction as @@ -635,6 +636,20 @@ Architectural shape: nothing; they surface as `proposed` outcomes in the same create report (always-in-report — no session flag). A caller accepts a proposal by issuing an ordinary `create_instance/3` for the proposed class. +- **Horizontal conflict precedence.** When a class and its taxonomy + ancestors carry rules that reference the same concept (composition: the + same-or-descendant child class; connection: the same characterization + + same-or-descendant target class), a **conflict resolver** picks one + winner per group before firing: the nearest-level member by mode + priority (`mandatory` > `auto` > `propose`), surviving `Min` is the + winner's and `Max` is the greatest across winner + dropped losers; a + loser is demoted to `propose` (rather than dropped) only when both it + and the winner carry a non-default template. The default policy is built + by `graphdb_rules:default_conflict_resolver/0` and applied per cascade + level (composition) and per node (connection); it is deadlock-safe + (reads only in-memory AVPs, dirty `relationships`, and `graphdb_class`). + Callers override the policy through `create_instance/5` — `/3` and `/4` + inject the default. - **Scope.** The API is scope-tagged (`environment` | `{project, _}`). It serves the `environment` scope; `{project, _}` creates are rejected and `{project, _}` reads return empty. diff --git a/docs/designs/f4-phase-b5-conflict-precedence-design.md b/docs/designs/f4-phase-b5-conflict-precedence-design.md new file mode 100644 index 0000000..c73f421 --- /dev/null +++ b/docs/designs/f4-phase-b5-conflict-precedence-design.md @@ -0,0 +1,299 @@ + + +# F4 Phase B / Division B5 — Horizontal Conflict Resolution / Precedence — Design + +**Status:** Designed; not yet planned or implemented. + +**Parent design:** `docs/designs/f4-graphdb-rules-design.md` (F4 Phase A +landed; B1 via PR #33; B2 via PR #34; B3 via PR #35; B4 via PR #37). This is +the fifth and final core division of Phase B. It consumes B1's +`effective_rules_for_class/2` gather, B2's composition firing engine and +rule-centric report, B3's propose machinery, and B4's connection firing +engine + resolver-threading pattern. + +**Spec citation:** `the-knowledge-network.md` §8 (Rules as Stored Data). +The knowledge-network spec stores rules as data and is silent on conflict +precedence; B5 supplies the precedence policy the firing engine applies. + +**Resolves:** OI-2 (rule conflicts and precedence) from the parent design. + +--- + +## 1. Scope + +### 1.1 Phase B division map + +| Div | Subject | Depends on | +| ------ | ------------------------------------------------------------------------------ | ---------- | +| **B1** | `effective_rules_for_class/2` — read-side taxonomy walk (no firing) | Phase A | +| **B2** | Composition firing engine — `mandatory` + `auto`; cascade; return-shape change | B1 | +| **B3** | `propose` mode — proposals surfaced in the create report (no session flag) | B2 | +| **B4** | Connection firing engine (Mandatory Connections) | B1, B2 | +| **B5** | Horizontal conflict resolution / precedence (OI-2) | B2, B4 | + +This document specifies **B5 only**. + +### 1.2 The problem + +When a class and its taxonomy ancestors each attach a rule that touches the +same component or connection, the effective-rules gather currently returns +them **additively, nearest-first, and resolves nothing**. Both +`graphdb_rules:composition_pairs/2` and `connection_specs/2` flatten the +level-grouped output of `effective_rules/2` into a single ordered list with +no deduplication. Today, if `Car` and its ancestor `Vehicle` both attach +"mandatory 1 `Engine`", the engine fires *two* engines. + +B5 inserts a **resolution pass** between the gather and the firing engines +that decides, when two effective rules conflict, which one wins, whether the +loser is dropped or surfaced as a proposal, and how the surviving +multiplicity range is computed. + +### 1.3 Out of scope + +- **No change to the public B1 contract.** `effective_rules_for_class/2` + stays additive/unresolved — its documented "resolves nothing" guarantee is + preserved. Resolution happens only on the firing path. +- **No new rule data.** B5 reads existing rule content and deployment AVPs; + it seeds nothing and adds no Mnesia fields. +- **Reactive learning, guided/automatic instantiation modes** — later + phases, tracked in `TASKS.md`. + +--- + +## 2. Decisions + +### B5-D1 — Conflict grouping is by referenced class, with descendant matching + +Two effective rules are in the same **conflict group** when their referenced +class matches under the taxonomy. Walking nearest-first, each rule either +joins the nearest already-established **winner** whose referenced class it +matches, or it becomes a new winner (its own group of one). Matching is +directional — the nearer rule must be the same as, or a **descendant of**, +the farther rule's referenced class: + +| Kind | Farther rule joins a winner's group when… | +| ----------- | ------------------------------------------------------------------------------------------------- | +| Composition | the winner's `child_class` **is-a** (descendant-or-self of) the farther's `child_class` | +| Connection | same `characterization` **and** the winner's `target_class` **is-a** the farther's `target_class` | + +If the referenced classes are taxonomically unrelated, there is **no match** +— the rules are **additive** and both fire independently. + +The `is-a` test is `graphdb_class:class_in_ancestry/2`, whose contract is +`class_in_ancestry(CandidateNref, ClassNref)` → true iff `ClassNref` **equals +or is a subclass of** `CandidateNref` (the *second* argument is the +descendant-or-self of the *first*). To test "the winner's child is-a the +farther's child," call it **`class_in_ancestry(FartherChild, WinnerChild)`** +— ancestor first, descendant second. (Arg order is an implementation hazard; +B4 added a wrong-arg-order canary test for the same call. Connection target +matching uses the same order: `class_in_ancestry(FartherTarget, +WinnerTarget)`.) + +*Rationale.* For composition the `child_class` **is** the slot, so a nearer +rule that mints a more specific child (`Car`→`ElectricMotor`, where +`ElectricMotor` is-a `Engine`) fills the same slot as the ancestor's generic +`Vehicle`→`Engine` rule — one child, the more specific wins. For connection +the `characterization` (the arc-label attribute) plus a descendant target is +the analogous "same edge, more specific endpoint" case (`Car`--owns-->`Garage` +refines `Vehicle`--owns-->`Building` when `Garage` is-a `Building`). Unrelated +targets under the same `characterization` are genuinely different connections. + +### B5-D2 — Winner is the nearest rule; same-level ties break by mode priority + +Within a conflict group the **winner is the nearest rule** (smallest +taxonomy distance). The winner contributes the surviving **mode** and the +surviving **`Min`**. + +When two rules in a group sit at the **same** class level (distance 0 — e.g. +the Phase A `duplicate_child_class_with_different_modes` fixture: two rules +on `Cell`, both `child_class=Nucleus`, one `mandatory` and one `propose`), +"nearest" cannot pick. Break the tie by **mode priority +`mandatory > auto > propose`**; if the mode also ties, break by **arc order** +(the order rules are encountered in the gather, which follows +`applies_to`-arc traversal). + +### B5-D3 — Surviving multiplicity is nearest `Min`, greatest `Max` + +The resolved multiplicity is: + +- **`Min`** = the **winner's** `Min` (the most specific floor; all other + `Min`s are ignored). +- **`Max`** = the **greatest** `Max` across the winner and all of its + **dropped** members (`unbounded` dominates). A member that is *demoted to + propose* (B5-D4) does **not** contribute its `Max` to this merge — it is + fully independent. + +Firing continues to mint `Min` (per B-prep); `Max` remains the ceiling for a +future interactive-creation session. + +### B5-D4 — Losers are dropped, except both-real-template losers, which demote to `propose` + +A farther (losing) member of a conflict group is **dropped** (shadowed); its +`Max` merges into the winner's greatest-`Max` per B5-D3. + +**Exception:** when **both** the winner **and** the losing member carry a +**real (non-default) template** (B5-D5), the loser is **not** dropped — it is +re-emitted as an independent **`propose`** entry, regardless of its original +mode, keeping its **own** `{Min, Max}`. Deliberate templated authoring is +surfaced to the caller as a proposal rather than silently discarded. + +If only one of the pair carries a real template (mixed), the loser is simply +**dropped** — the propose-demotion requires *both* to be real-templated. + +### B5-D5 — "Real (non-default) template" is read from the content `template_nref` AVP + +The deployment `?ARC_TEMPLATE` AVP is always set to the rule's *owning +class's default template* (`graphdb_rules:do_create_rule/7`), so it can never +be the signal for "this rule targets a specific template slot." The signal +is the optional **content** `template_nref` AVP on the rule node (the +caller-supplied `TemplateNref`, absent for most rules). A rule has a **real +(non-default) template** iff it carries a `template_nref` content AVP whose +value differs from its owning class's default template. + +### B5-D6 — The resolver is owned by `graphdb_instance` and threaded in, mirroring B4 + +`graphdb_rules`' plan path **stops resolving on its own**. +`graphdb_instance` owns the conflict-resolution policy and supplies it, +exactly as B4 threads a connection resolver through `create_instance/4`: + +- `create_instance` gains a **conflict-resolver parameter** (a fun; default + = the B5 algorithm in this document). The new arity is + `create_instance/5 (Name, ClassNref, ParentNref, ConnResolver, + ConflictResolver)`; `/3` and `/4` delegate with the built-in B5 default. +- For **composition**, the resolver is threaded **into** + `graphdb_rules:plan_composition_firing` (new arity taking the resolver) and + applied at **each cascade level** inside `plan_node`'s recursion — the plan + is built per-level, so resolution must run per-level, not on a + pre-flattened list. +- For **connection**, `graphdb_instance` applies the **same** resolver to the + output of `graphdb_rules:effective_connection_rules/2` during the B4 + RESOLVE walk, per plan node. + +This keeps the policy in the instance layer (per-call overridable, symmetric +with the connection resolver) while the **default** algorithm lives in +`graphdb_rules`, next to the rule content and taxonomy access it needs. + +### B5-D7 — Integration is free; demoted entries flow through existing machinery + +The resolution pass only **rewrites and reorders** the +`[{RuleNode, Deploy}]` (composition) and `[{RuleNode, Deploy, ConnSpec}]` +(connection) lists. No firing-engine changes are required: + +- A demoted composition entry (`mode => propose`) flows through B3's existing + `propose_rules` accumulator → `fire_propose` → `proposed` outcome. +- A demoted connection entry (`mode => propose`) flows through B4's existing + propose handling (resolver not consulted; `proposed` outcome emitted). +- A winner with a merged `{Min, Max}` fires exactly as any rule with that + deployment. + +--- + +## 3. The resolver contract + +The conflict resolver is a fun supplied at `create_instance` and threaded as +in B5-D6. It is invoked **per cascade level / per plan node**, on the +nearest-first, meta-class-filtered rule list for that level, and returns the +**resolved** list in the shape its consumer already expects. + +Because composition pairs (`{RuleNode, Deploy}`) and connection specs +(`{RuleNode, Deploy, ConnSpec}`) differ in shape, the resolver receives a +**conflict context** map identifying the kind and carrying the level's +rules, and returns the resolved list of the same kind: + +``` +ConflictResolver :: + fun((#{ kind := composition | connection, + rules := [Pair], %% nearest-first, this level only + class_nref := integer() }) -> [Pair]) +``` + +- `kind = composition` → `Pair = {RuleNode, Deploy}`. +- `kind = connection` → `Pair = {RuleNode, Deploy, ConnSpec}`. + +The **default** resolver (`graphdb_rules:default_conflict_resolver/0`, or an +internal function the default fun wraps) implements §2 in full for both +kinds: group by B5-D1, resolve by B5-D2/D3, dispose by B5-D4/D5. A caller +may pass a custom fun to override the policy entirely (e.g. force pure +additive, or pure nearest-shadow). + +*Note.* Grouping spans the whole effective set for a node (self + all +ancestors), so the resolver is given the **flattened** nearest-first list for +that node, not the per-ancestor sublists. For composition this is the list +`plan_node` currently builds via `composition_pairs/2`; for connection it is +`effective_connection_rules/2`'s output for that node. + +--- + +## 4. Worked examples + +| Scenario | Effective rules (nearest-first) | Resolved outcome | +| ------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| **Cross-level shadow** | `Car`: mandatory `{1,1}` Engine; `Vehicle`: mandatory `{1,3}` Engine | One winner: mandatory `{1,3}` Engine (nearest mode+Min, greatest Max). One engine minted. | +| **Descendant shadow** (B5-D1) | `Car`: mandatory `{1,1}` ElectricMotor (is-a Engine); `Vehicle`: mandatory `{1,1}` Engine | One winner: mandatory `{1,1}` ElectricMotor. One ElectricMotor minted; Vehicle's Engine rule shadowed. | +| **Additive** (unrelated) | `Car`: mandatory `{1,1}` Engine; `Vehicle`: mandatory `{1,1}` Radio (unrelated) | Two winners, both fire: one Engine + one Radio. | +| **Same-level tie** (B5-D2) | `Cell`: mandatory `{1,1}` Nucleus; `Cell`: propose `{1,1}` Nucleus | Mode priority → mandatory wins; one Nucleus minted. (Propose loser dropped — default templates.) | +| **Both-real-template demote** (B5-D4) | `Car`@tpl-A: auto `{1,1}` Engine; `Vehicle`@tpl-B: mandatory `{1,2}` Engine (both real tpls) | Winner: auto `{1,1}` Engine (fires). Loser re-emitted as **propose** `{1,2}` Engine (own range). | +| **Mixed template drop** (B5-D4) | `Car`@tpl-A: auto `{1,1}` Engine; `Vehicle`@default: mandatory `{1,2}` Engine | Winner: auto `{1,2}` Engine (greatest Max merged). Loser dropped — not both real templates. | +| **Connection target shadow** (B5-D1) | `Car`--owns-->Garage (Garage is-a Building); `Vehicle`--owns-->Building, same `owns` label | One winner: Car--owns-->Garage. Vehicle's Building connection shadowed. | +| **Connection additive** (unrelated targets) | `Car`--owns-->Garage; `Vehicle`--owns-->Boat (unrelated), same `owns` label | Both fire: owns-->Garage and owns-->Boat. | + +--- + +## 5. Edge cases + +- **Farther rule matches two unrelated winners.** A farther rule whose + referenced class is a common ancestor of two unrelated winners' classes + joins **only the nearest** winner it matches (single assignment); it does + not merge into both. +- **Same-level descendant-related rules** (two rules on one class whose child + classes are in a subclass relation) — resolved by B5-D2 (mode priority, + then arc order); the winner's own referenced class is the one minted. +- **Bad / unknown / non-class starting nref** — unchanged: the existing + `ancestors/1 → {error,_} ⇒ []` mapping in `effective_rules` keeps the + effective set empty, so resolution sees an empty list and returns `[]`. +- **Custom resolver returning a malformed list** — out of scope; the default + resolver is total over well-formed input, and a caller passing a custom fun + owns its correctness (same posture as B4's connection resolver). + +--- + +## 6. Testing + +New CT cases in the rules / instance suites, one per path: + +- cross-level shadow (nearest mode + Min, greatest Max); +- descendant-match shadow (the `Car`/`ElectricMotor` case); +- additive when referenced classes are unrelated; +- max-of-all merge across ≥ 3 levels including `unbounded`; +- same-level mode-priority tie (the `Cell`/`Nucleus` fixture — its outcome + flips under B5, so the Phase A fixture's "no dedup" assertion is updated to + the firing-time resolution); +- both-real-template demote-to-propose (loser surfaces as `proposed` with its + own range; winner fires at its own range); +- mixed-template drop (no propose; greatest-Max merge); +- connection target descendant-match shadow; +- connection additive on unrelated targets; +- a `create_instance/5` call passing a **custom** resolver (e.g. pure + additive) to prove the seam is overridable. + +--- + +## 7. Open items + +- **OI-B5-1 — Resolved-rule provenance in the report.** The rule-centric + report currently names the winning rule node. When a loser is shadowed + (dropped), the report does not record that a farther rule was suppressed. + A future enhancement could add a `shadowed_by` / `shadows` note. Deferred + — not engine-correctness. +- **OI-B5-2 — Resolver as ontology.** B4 raised OI-B4-1 (resolvers expressed + as ontology rather than caller-supplied funs). The conflict resolver shares + that future: the default policy could be encoded as ontology metadata a + class author tunes. Deferred with OI-B4-1. +- **OI-B5-3 — Multi-class instance creation interaction.** When OI-B4-3 + (multi-class creation via transitive gather) is taken up, B5 grouping must + span the union of all conferred classes' rules; the per-node resolver + contract already operates on the flattened effective set, so the change is + in the gather, not the resolver. Tracked with OI-B4-3. diff --git a/docs/superpowers/plans/2026-06-14-f4-phase-b5-conflict-precedence.md b/docs/superpowers/plans/2026-06-14-f4-phase-b5-conflict-precedence.md new file mode 100644 index 0000000..e1e0870 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-f4-phase-b5-conflict-precedence.md @@ -0,0 +1,1133 @@ + + +# F4 Phase B5 — Horizontal Conflict Precedence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> (recommended) or superpowers:executing-plans to implement this plan task-by-task. +> Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Insert a conflict-resolution pass on the rule-firing path so that when +a class and its taxonomy ancestors attach rules touching the same component or +connection, one rule wins, multiplicity is merged, and templated losers surface +as proposals — without changing the additive B1 read contract. + +**Architecture:** A conflict-resolver fun is threaded through `create_instance` +(mirroring B4's connection resolver). The **default** resolver is built by +`graphdb_rules:default_conflict_resolver/0` — a closure that bakes in the seeded +attribute nrefs (read once in the caller's process) and dispatches on a `kind` +field. For **composition** the resolver is applied inside `graphdb_rules:plan_node` +(per cascade level); for **connection** it is applied inside +`graphdb_instance:resolve_nodes` (per plan node). Both apply points run the same +fun; it touches only in-memory `#node` AVPs plus `graphdb_class` (a different +gen_server), so it is deadlock-safe in either process. + +**Tech Stack:** Erlang/OTP 28.5, rebar3 3.27.0, Mnesia, Common Test. Build with +`./rebar3 compile`. Run a single CT suite case with +`./rebar3 ct --suite apps/graphdb/test/ --case `. + +**Design:** `docs/designs/f4-phase-b5-conflict-precedence-design.md` +(decisions B5-D1…B5-D7). + +--- + +## Background the engineer needs + +### The two seams + +- **Composition.** `graphdb_rules:plan_node/6` (runs *inside the graphdb_rules + gen_server process*) calls `composition_pairs/2` — a flattened, nearest-first + `[{RuleNode, Deploy}]` list across the class and all its taxonomy ancestors — + then hands it to `plan_rules/4`. B5 transforms that list before `plan_rules`. +- **Connection.** `graphdb_instance:resolve_nodes/3` (runs *inside the + graphdb_instance gen_server process*) calls + `graphdb_rules:effective_connection_rules/2` — a flattened, nearest-first + `[{Rule, Deploy, Spec}]` list where each `Spec` is + `#{characterization, reciprocal, target_class}` — then hands it to + `resolve_rules/4`. B5 transforms that list before `resolve_rules`. + +### Why the default resolver is a closure, not an atom + +The conflict resolver is **always a fun** (mirrors B4's `report_only/1` +connection-resolver default). `create_instance/3` and `/4` inject the default by +calling `graphdb_rules:default_conflict_resolver/0` **in the caller's process**, +where the `seeded_nrefs/0` gen_server call is safe (no self-call). That function +returns ONE closure that has the three seed nrefs it needs baked in +(`child_class_nref_attr`, `template_nref_attr`, `applied_by`) and dispatches on +the context's `kind`. The closure never calls back into the `graphdb_rules` +gen_server, so applying it inside the `graphdb_rules` process (composition) does +not deadlock. It does call `graphdb_class` — that is a *different* gen_server, and +`plan_mandatory` already calls `graphdb_class:is_instantiable/1` from inside the +rules process (`graphdb_rules.erl:1019`), proving such calls are safe there. + +### The B5 algorithm (default resolver), per cascade level / per node + +Operating on the nearest-first list for ONE class: + +1. **Group** (B5-D1). Walk nearest-first. Each rule joins the first existing + group whose nearest (anchor) member it *matches*, else starts a new group. + - Composition match: the anchor's `child_class` **is-a** (descendant-or-self + of) the candidate's `child_class`. + - Connection match: same `characterization` **and** anchor's `target_class` + **is-a** the candidate's `target_class`. + - The is-a test is `graphdb_class:class_in_ancestry(FartherRef, NearerRef)` — + **ancestor first, descendant second** (arg-order hazard; B4 has a canary + test for the same call). Here `FartherRef` = candidate's ref, `NearerRef` = + anchor's ref. +2. **Winner** (B5-D2). Within a group, the nearest level's members are the + prefix that shares the head member's owning class. The winner is the + highest-mode-priority member of that prefix (`mandatory > auto > propose`), + ties broken by arc/encounter order. The winner contributes the surviving + **mode** and **`Min`**. +3. **Multiplicity** (B5-D3). Surviving `Min` = winner's `Min`. Surviving `Max` = + greatest `Max` across the winner and its **dropped** losers (`unbounded` + dominates). Demoted-to-propose losers do **not** contribute to the merge. +4. **Disposition** (B5-D4/D5). A loser is **dropped** unless **both** the winner + and that loser carry a **real (non-default) template** — then the loser is + re-emitted as an independent `propose` entry keeping its own `{Min, Max}`. A + "real template" is a content `template_nref` AVP whose value differs from the + rule's *owning class's default template* (re-derived from the rule's + `applied_by` arc). + +### File structure + +| File | Responsibility for B5 | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `apps/graphdb/src/graphdb_rules.erl` | `default_conflict_resolver/0`; the private resolver algorithm; `plan_composition_firing/3`; thread the resolver through `plan_node`/`plan_rules`/`plan_mandatory`/`expand_children` | +| `apps/graphdb/src/graphdb_instance.erl` | `create_instance/5`; carry `conflict_resolver` in `Ctx`; apply it in `resolve_nodes` | +| `apps/graphdb/test/graphdb_rules_SUITE.erl` | resolver-algorithm CT cases (composition + connection grouping, merge, demote) | +| `apps/graphdb/test/graphdb_instance_SUITE.erl`| firing-time CT cases proving resolution end-to-end + the custom-resolver override | +| `apps/graphdb/CLAUDE.md`, `docs/Architecture.md` (if contract shifts), `docs/diagrams/ontology-tree.md` (no change — B5 seeds nothing) | docs | + +The B1 public read contract (`effective_rules_for_class/2`, +`plan_composition_firing/2`) is **preserved as additive** (design §1.3). +Resolution happens only on the `create_instance` firing path. + +--- + +## Task 1: Thread a conflict-resolver fun through the firing path (behaviour-preserving) + +Introduce the plumbing with an **identity** default resolver so the whole suite +stays green and behaviour is unchanged. Later tasks replace the default body. + +**Files:** +- Modify: `apps/graphdb/src/graphdb_rules.erl` (add `default_conflict_resolver/0`, + `plan_composition_firing/3`, thread `Resolver` through the plan internals) +- Modify: `apps/graphdb/src/graphdb_instance.erl` (`create_instance/5`, `Ctx`, + `resolve_nodes` apply point) +- Test: `apps/graphdb/test/graphdb_instance_SUITE.erl` + +- [ ] **Step 1: Write the failing test (identity default ⇒ unchanged firing; /5 accepted)** + +Add to `apps/graphdb/test/graphdb_instance_SUITE.erl`. Add both names to `all/0` +(or the relevant group list) so they run. These cases build their own classes +(they do not need the `ob`/`obw` fixtures; the worker stack is started by +`init_per_testcase` unconditionally). + +```erlang +%%----------------------------------------------------------------------------- +%% B5 plumbing: create_instance/5 is accepted and, with the default resolver, +%% a single inherited mandatory rule still fires exactly as /3 (no regression). +%%----------------------------------------------------------------------------- +b5_create_instance_5_accepts_resolvers(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 1}), + Conn = fun(_Ctx) -> defer end, + Conflict = graphdb_rules:default_conflict_resolver(), + {ok, Root, Report} = + graphdb_instance:create_instance("car", Vehicle, 5, Conn, Conflict), + {ok, Kids} = graphdb_instance:children(Root), + ?assertEqual(1, length(Kids)), + ?assertEqual(#{fired => 1, failed => 0, not_attempted => 0, proposed => 0, + connected => 0, required => 0, not_connected => 0}, + graphdb_instance:summarize(Report)). + +%%----------------------------------------------------------------------------- +%% B5 plumbing: /3 (built-in default conflict resolver) is unchanged for a +%% plain single-rule fire. +%%----------------------------------------------------------------------------- +b5_default_resolver_single_rule_unchanged(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 1}), + {ok, Root, Report} = graphdb_instance:create_instance("car", Vehicle, 5), + {ok, Kids} = graphdb_instance:children(Root), + ?assertEqual(1, length(Kids)), + ?assertEqual(1, length(Report)). +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: +``` +./rebar3 ct --suite apps/graphdb/test/graphdb_instance_SUITE --case b5_create_instance_5_accepts_resolvers +``` +Expected: FAIL — `graphdb_instance:create_instance/5` and +`graphdb_rules:default_conflict_resolver/0` are undefined. + +- [ ] **Step 3: Add `default_conflict_resolver/0` (identity) and export it in `graphdb_rules`** + +In `graphdb_rules.erl`, add `default_conflict_resolver/0` and +`plan_composition_firing/3` to the `-export([...])` list. Add this function near +`plan_composition_firing/2` (around `graphdb_rules.erl:322`): + +```erlang +%%----------------------------------------------------------------------------- +%% default_conflict_resolver() -> fun((ConflictContext) -> [Pair]) +%% +%% The built-in B5 conflict resolver. Called in the CALLER's process (e.g. from +%% graphdb_instance:create_instance/3,4), where seeded_nrefs/0 is safe. Returns +%% ONE closure that bakes in the seed nrefs it needs and dispatches on the +%% context `kind'. The closure is deadlock-safe in either the graphdb_rules or +%% graphdb_instance process: it touches only in-memory #node AVPs, the +%% relationships table (dirty), and graphdb_class (a different gen_server). +%% +%% ConflictContext :: #{kind := composition | connection, +%% rules := [Pair], class_nref := integer()} +%% kind = composition -> Pair = {RuleNode, Deploy} +%% kind = connection -> Pair = {RuleNode, Deploy, ConnSpec} +%% +%% This identity stub is replaced by the real algorithm in Task 2/3/4. +%%----------------------------------------------------------------------------- +default_conflict_resolver() -> + fun(#{rules := Rules}) -> Rules end. +``` + +- [ ] **Step 4: Add `plan_composition_firing/3` and thread `Resolver` through the plan internals** + +Add the public arity beside `/2` (`graphdb_rules.erl:322`): + +```erlang +%%----------------------------------------------------------------------------- +%% plan_composition_firing(Scope, ClassNref, ConflictResolver) -> +%% {ok, PlanNode} | {error, Reason, #{plan_so_far, culprit}} +%% +%% As /2, but applies ConflictResolver to each cascade level's composition pairs +%% before planning. /2 is preserved as the additive (unresolved) public read. +%%----------------------------------------------------------------------------- +plan_composition_firing(Scope, ClassNref, ConflictResolver) -> + gen_server:call(?MODULE, + {plan_composition_firing, Scope, ClassNref, ConflictResolver}). +``` + +Add the two matching `handle_call` clauses next to the existing +`plan_composition_firing` clauses (`graphdb_rules.erl:501-505`): + +```erlang +handle_call({plan_composition_firing, environment, ClassNref, Resolver}, + _From, State) -> + Reply = plan_node(ClassNref, root, undefined, undefined, [], State, Resolver), + {reply, Reply, State}; +handle_call({plan_composition_firing, {project, _}, ClassNref, _Resolver}, + _From, State) -> + {reply, {ok, leaf_plan(ClassNref, root, undefined, undefined)}, State}; +``` + +Keep the existing `/2` (3-tuple) clauses, but route them through an identity +resolver so there is a single planning path. Replace the existing environment +clause body (`graphdb_rules.erl:501-502`) with: + +```erlang +handle_call({plan_composition_firing, environment, ClassNref}, _From, State) -> + Identity = fun(#{rules := R}) -> R end, + Reply = plan_node(ClassNref, root, undefined, undefined, [], State, Identity), + {reply, Reply, State}; +``` + +Thread `Resolver` through `plan_node`, `plan_rules`, `plan_mandatory`, and +`expand_children`. Replace `plan_node/6` (`graphdb_rules.erl:944-948`): + +```erlang +plan_node(ClassNref, Rule, Deploy, Name, OnPath, State, Resolver) -> + OnPath1 = [ClassNref | OnPath], + CompRules0 = composition_pairs(ClassNref, State), + CompRules = Resolver(#{kind => composition, rules => CompRules0, + class_nref => ClassNref}), + plan_rules(CompRules, OnPath1, State, Resolver, + leaf_plan(ClassNref, Rule, Deploy, Name)). +``` + +Replace `plan_rules/4` (`graphdb_rules.erl:987-1007`) — add `Resolver` as the +4th argument (before `Acc`) and pass it through both the recursive calls and +`plan_mandatory`: + +```erlang +plan_rules([], _OnPath1, _State, _Resolver, Acc) -> + {ok, Acc}; +plan_rules([{RuleNode, Deploy} | Rest], OnPath1, State, Resolver, Acc) -> + case maps:get(mode, Deploy, undefined) of + auto -> + Autos = maps:get(auto_rules, Acc) ++ [{RuleNode, Deploy}], + plan_rules(Rest, OnPath1, State, Resolver, + Acc#{auto_rules => Autos}); + propose -> + Proposes = maps:get(propose_rules, Acc) ++ [{RuleNode, Deploy}], + plan_rules(Rest, OnPath1, State, Resolver, + Acc#{propose_rules => Proposes}); + mandatory -> + case plan_mandatory(RuleNode, Deploy, OnPath1, State, Resolver, Acc) of + {ok, Acc1} -> plan_rules(Rest, OnPath1, State, Resolver, Acc1); + {error, _, _} = Err -> Err + end; + _ -> + plan_rules(Rest, OnPath1, State, Resolver, Acc) + end. +``` + +Replace `plan_mandatory/5` (`graphdb_rules.erl:1011-1030`) — add `Resolver` +(before `Acc`) and pass it to `expand_children`: + +```erlang +plan_mandatory(RuleNode, Deploy, OnPath1, State, Resolver, Acc) -> + ChildClass = content_avp_value(RuleNode, + State#state.child_class_nref_attr), + case lists:member(ChildClass, OnPath1) of + true -> + {ok, Acc}; + false -> + {Min, _Max} = maps:get(multiplicity, Deploy, {1, 1}), + case graphdb_class:is_instantiable(ChildClass) of + true -> + expand_children(RuleNode, Deploy, ChildClass, Min, 1, + OnPath1, State, Resolver, Acc); + false -> + fail({class_not_instantiable, ChildClass}, + RuleNode, Acc); + {error, Reason} -> + fail({child_class_invalid, ChildClass, Reason}, + RuleNode, Acc) + end + end. +``` + +Replace `expand_children/8` (`graphdb_rules.erl:1038-1053`) — add `Resolver` +(before `Acc`) and pass it to the recursive `plan_node`: + +```erlang +expand_children(_RuleNode, _Deploy, _ChildClass, Mult, I, _OnPath1, _State, + _Resolver, Acc) when I > Mult -> + {ok, Acc}; +expand_children(RuleNode, Deploy, ChildClass, Mult, I, OnPath1, State, Resolver, + Acc) -> + Name = resolve_child_name(RuleNode, ChildClass, I, Mult, State), + case plan_node(ChildClass, RuleNode, Deploy, Name, OnPath1, State, Resolver) of + {ok, ChildPlan} -> + Kids = maps:get(mandatory_children, Acc) ++ [ChildPlan], + expand_children(RuleNode, Deploy, ChildClass, Mult, I + 1, OnPath1, + State, Resolver, Acc#{mandatory_children => Kids}); + {error, R, Failure} -> + {error, R, Failure#{plan_so_far => Acc}} + end. +``` + +- [ ] **Step 5: Add `create_instance/5`, carry the resolver in `Ctx`, apply it for connections** + +In `graphdb_instance.erl`, add `create_instance/5` to `-export([...])`. Replace +the `create_instance/3` and `/4` clauses (`graphdb_instance.erl:184-198`): + +```erlang +create_instance(Name, ClassNref, ParentNref) -> + create_instance(Name, ClassNref, ParentNref, fun report_only/1). + +create_instance(Name, ClassNref, ParentNref, ConnResolver) + when is_function(ConnResolver, 1) -> + create_instance(Name, ClassNref, ParentNref, ConnResolver, + graphdb_rules:default_conflict_resolver()). + +create_instance(Name, ClassNref, ParentNref, ConnResolver, ConflictResolver) + when is_function(ConnResolver, 1), is_function(ConflictResolver, 1) -> + gen_server:call(?MODULE, + {create_instance, Name, ClassNref, ParentNref, ConnResolver, + ConflictResolver}). +``` + +Replace the `create_instance` `handle_call` clause (`graphdb_instance.erl:375-379`): + +```erlang +handle_call({create_instance, Name, ClassNref, ParentNref, Resolver, + ConflictResolver}, _From, + #state{instantiable_nref = InstAttr} = State) -> + Ctx = #{inst_attr => InstAttr, on_path => [], resolver => Resolver, + conflict_resolver => ConflictResolver, + root_parent => ParentNref, root_source => undefined}, + {reply, do_create_instance(Name, ClassNref, ParentNref, Ctx), State}; +``` + +In `fire_create/4`, change the plan call (`graphdb_instance.erl:498`) to thread +the resolver: + +```erlang + case graphdb_rules:plan_composition_firing(?RULE_SCOPE, ClassNref, + maps:get(conflict_resolver, Ctx)) of +``` + +In `resolve_nodes/3`, apply the same resolver to the effective connection rules +before `resolve_rules`. Replace `graphdb_instance.erl:604-610`: + +```erlang +resolve_nodes([{SourceNref, Class} | Rest], Ctx, Acc) -> + {ok, ConnRules0} = + graphdb_rules:effective_connection_rules(?RULE_SCOPE, Class), + ConflictResolver = maps:get(conflict_resolver, Ctx), + ConnRules = ConflictResolver(#{kind => connection, rules => ConnRules0, + class_nref => Class}), + case resolve_rules(ConnRules, SourceNref, Ctx, Acc) of + {ok, Acc1} -> resolve_nodes(Rest, Ctx, Acc1); + {error, _, _} = Err -> Err + end. +``` + +- [ ] **Step 6: Run the new tests and the full graphdb suites to verify green** + +Run: +``` +./rebar3 ct --suite apps/graphdb/test/graphdb_instance_SUITE --case b5_create_instance_5_accepts_resolvers +./rebar3 ct --suite apps/graphdb/test/graphdb_instance_SUITE --case b5_default_resolver_single_rule_unchanged +make test-ct-parallel FILTER=graphdb_instance FILTER=graphdb_rules +``` +Expected: PASS; no regressions in either suite (identity resolver ⇒ no +behaviour change). + +- [ ] **Step 7: Commit** + +```bash +git add apps/graphdb/src/graphdb_rules.erl apps/graphdb/src/graphdb_instance.erl \ + apps/graphdb/test/graphdb_instance_SUITE.erl +git commit -m "F4 B5 T1: thread conflict-resolver fun through firing path (identity default) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 2: Default composition resolution — group, shadow, merge multiplicity + +Replace the identity default with the real composition algorithm (B5-D1/D2/D3), +**losers always dropped** (template demotion arrives in Task 3; here all +fixtures use default templates, so dropping is correct). The connection clause +of `resolve_conflicts/4` is added as a pass-through so connections stay additive +until Task 4. + +**Files:** +- Modify: `apps/graphdb/src/graphdb_rules.erl` +- Test: `apps/graphdb/test/graphdb_rules_SUITE.erl` + +- [ ] **Step 1: Write the failing tests (composition grouping/shadow/merge)** + +Add to `apps/graphdb/test/graphdb_rules_SUITE.erl` and register them in `all/0` +(or the relevant group). These drive the default resolver directly so the +assertions are precise. Helper `make_class/1` exists +(`graphdb_rules_SUITE.erl:1372`); use `graphdb_class:create_class/2` for +subclasses. + +```erlang +%%----------------------------------------------------------------------------- +%% Cross-level shadow: Car (nearest) and Vehicle (ancestor) both mandate Engine. +%% Resolved to ONE pair: nearest mode + nearest Min, greatest Max. +%%----------------------------------------------------------------------------- +b5_comp_cross_level_shadow(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CE", Car, Engine, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 3}), + {ok, [{_R, Dep}]} = resolve_comp(Car), + ?assertEqual(mandatory, maps:get(mode, Dep)), + ?assertEqual({1, 3}, maps:get(multiplicity, Dep)). + +%%----------------------------------------------------------------------------- +%% Descendant shadow (B5-D1): Car mandates ElectricMotor (is-a Engine); +%% Vehicle mandates Engine. ElectricMotor wins; Vehicle's Engine rule shadowed. +%%----------------------------------------------------------------------------- +b5_comp_descendant_shadow(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, EMotor} = graphdb_class:create_class("ElectricMotor", Engine), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CEM", Car, EMotor, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 1}), + {ok, [{R, _Dep}]} = resolve_comp(Car), + ?assertEqual(EMotor, graphdb_rules:rule_child_class(R)). + +%%----------------------------------------------------------------------------- +%% Additive: unrelated child classes both survive (two pairs). +%%----------------------------------------------------------------------------- +b5_comp_additive_unrelated(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, Radio} = graphdb_class:create_class("Radio", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CE", Car, Engine, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VR", Vehicle, Radio, mandatory, {1, 1}), + {ok, Pairs} = resolve_comp(Car), + ?assertEqual(2, length(Pairs)). + +%%----------------------------------------------------------------------------- +%% Greatest-Max merge across 3 levels including unbounded -> unbounded dominates. +%%----------------------------------------------------------------------------- +b5_comp_max_merge_unbounded(_Config) -> + {ok, A} = graphdb_class:create_class("A", 3), + {ok, B} = graphdb_class:create_class("B", A), + {ok, C} = graphdb_class:create_class("C", B), + {ok, Eng} = graphdb_class:create_class("Engine", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CE", C, Eng, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "BE", B, Eng, mandatory, {1, 2}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "AE", A, Eng, mandatory, {1, unbounded}), + {ok, [{_R, Dep}]} = resolve_comp(C), + ?assertEqual({1, unbounded}, maps:get(multiplicity, Dep)). + +%%----------------------------------------------------------------------------- +%% Same-level mode-priority tie (B5-D2): two rules on Cell, both child=Nucleus, +%% one mandatory one propose. mandatory wins; one pair survives. +%%----------------------------------------------------------------------------- +b5_comp_same_level_mode_priority(_Config) -> + {ok, Cell} = graphdb_class:create_class("Cell", 3), + {ok, Nucleus} = graphdb_class:create_class("Nucleus", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CN-prop", Cell, Nucleus, propose, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CN-mand", Cell, Nucleus, mandatory, {1, 1}), + {ok, [{_R, Dep}]} = resolve_comp(Cell), + ?assertEqual(mandatory, maps:get(mode, Dep)). +``` + +Add a small test helper at the bottom of the suite (near the other helpers, +e.g. after `make_class/1`): + +```erlang +%% resolve_comp(ClassNref) -> {ok, [{#node{}, Deploy}]} +%% Drives the default conflict resolver over the composition rules effective for +%% ClassNref, exactly as plan_node would. +resolve_comp(ClassNref) -> + {ok, Effective} = graphdb_rules:effective_rules_for_class(environment, + ClassNref), + Pairs = [P || {_Level, LvlPairs} <- Effective, P <- LvlPairs, + is_composition_pair(P)], + Resolver = graphdb_rules:default_conflict_resolver(), + {ok, Resolver(#{kind => composition, rules => Pairs, class_nref => ClassNref})}. + +%% is_composition_pair({RuleNode, _Deploy}) -> boolean() +%% A pair is composition iff its rule node is a CompositionRule instance. +is_composition_pair({#node{classes = Classes}, _Deploy}) -> + {ok, S} = graphdb_rules:seeded_nrefs(), + lists:member(maps:get(composition_rule, S), Classes). +``` + +(The suite already includes the `#node` record via its module's record +definitions and `graphdb/include/graphdb_nrefs.hrl`. `effective_rules_for_class/2` +returns the additive, level-grouped list — the same input `plan_node` resolves.) + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: +``` +./rebar3 ct --suite apps/graphdb/test/graphdb_rules_SUITE --case b5_comp_cross_level_shadow +``` +Expected: FAIL — the identity resolver returns both pairs, so the +`{ok, [{_R, Dep}]}` single-element match fails. + +- [ ] **Step 3: Replace `default_conflict_resolver/0` with the seed-baking closure** + +In `graphdb_rules.erl`, replace the identity body from Task 1: + +```erlang +default_conflict_resolver() -> + {ok, Seeds} = seeded_nrefs(), + ChildAttr = maps:get(child_class_nref_attr, Seeds), + TplAttr = maps:get(template_nref_attr, Seeds), + AppliedBy = maps:get(applied_by, Seeds), + fun(Ctx) -> resolve_conflicts(Ctx, ChildAttr, TplAttr, AppliedBy) end. +``` + +- [ ] **Step 4: Add the resolver algorithm (composition clause + shared helpers)** + +Add to `graphdb_rules.erl` (a new private section near the plan path). The +connection clause is a pass-through here; Task 4 replaces it. `comp_item`'s +`real_tpl` is `false` here (templates considered in Task 3) so every loser is +dropped. + +```erlang +%%--------------------------------------------------------------------- +%% B5 conflict resolution (default resolver body) +%%--------------------------------------------------------------------- +%% resolve_conflicts(Ctx, ChildAttr, TplAttr, AppliedBy) -> [Pair] +%% Ctx = #{kind, rules, class_nref}. Pure over the seed nrefs + graphdb_class + +%% the relationships table; no graphdb_rules gen_server call (deadlock-safe in +%% either process). + +resolve_conflicts(#{kind := composition, rules := Pairs}, ChildAttr, TplAttr, + AppliedBy) -> + Items = [comp_item(P, ChildAttr, TplAttr, AppliedBy) || P <- Pairs], + Groups = assign_groups(Items, composition), + lists:flatmap(fun(G) -> resolve_group(G, composition) end, Groups); +resolve_conflicts(#{kind := connection, rules := Specs}, _ChildAttr, _TplAttr, + _AppliedBy) -> + %% Additive pass-through until Task 4 implements connection resolution. + Specs. + +%% comp_item({RuleNode, Deploy}, ChildAttr, TplAttr, AppliedBy) -> item() +%% item() = #{pair, ref, char, mode, min, max, owner, real_tpl} +comp_item({RuleNode, Deploy} = Pair, ChildAttr, _TplAttr, AppliedBy) -> + {Min, Max} = maps:get(multiplicity, Deploy, {1, 1}), + #{pair => Pair, + ref => content_avp_value(RuleNode, ChildAttr), + char => undefined, + mode => maps:get(mode, Deploy, mandatory), + min => Min, + max => Max, + owner => owning_class(RuleNode, AppliedBy), + real_tpl => false}. + +%% owning_class(RuleNode, AppliedBy) -> integer() | undefined +%% Re-derives the rule's owning class from its applied_by arc (source=Rule, +%% char=applied_by -> target=owning class). See do_create_rule/7. +owning_class(#node{nref = RuleNref}, AppliedBy) -> + Arcs = mnesia:dirty_index_read(relationships, RuleNref, + #relationship.source_nref), + case [A#relationship.target_nref || A <- Arcs, + A#relationship.characterization =:= AppliedBy] of + [Owner | _] -> Owner; + [] -> undefined + end. + +%% assign_groups(Items, Kind) -> [[item()]] +%% Walks nearest-first; each item joins the first group whose head (anchor = +%% nearest member) it matches, else starts a new group. Groups preserve +%% nearest-first member order; group list preserves creation order. +assign_groups(Items, Kind) -> + lists:foldl(fun(Item, Groups) -> + case find_group(Item, Groups, Kind, 1) of + {Idx, _G} -> append_to_group(Idx, Item, Groups); + none -> Groups ++ [[Item]] + end + end, [], Items). + +find_group(_Item, [], _Kind, _Idx) -> + none; +find_group(Item, [G | Rest], Kind, Idx) -> + case same_conflict(Kind, hd(G), Item) of + true -> {Idx, G}; + false -> find_group(Item, Rest, Kind, Idx + 1) + end. + +append_to_group(Idx, Item, Groups) -> + {Before, [G | After]} = lists:split(Idx - 1, Groups), + Before ++ [G ++ [Item]] ++ After. + +%% same_conflict(Kind, Anchor, Item) -> boolean() +%% The anchor (nearest member) must be same-or-descendant of the candidate. +%% class_in_ancestry(FartherRef, NearerRef): ANCESTOR first, DESCENDANT second +%% (arg-order hazard -- B4 has a canary for the same call). FartherRef = +%% candidate's ref, NearerRef = anchor's ref. +same_conflict(composition, Anchor, Item) -> + graphdb_class:class_in_ancestry(maps:get(ref, Item), maps:get(ref, Anchor)); +same_conflict(connection, Anchor, Item) -> + maps:get(char, Anchor) =:= maps:get(char, Item) + andalso graphdb_class:class_in_ancestry(maps:get(ref, Item), + maps:get(ref, Anchor)). + +%% resolve_group(Group, Kind) -> [Pair] +%% Winner = highest mode-priority among the nearest-level prefix; losers are +%% dropped (their Max merges) unless both winner and loser are real-templated, +%% in which case the loser is re-emitted as an independent propose (B5-D4). +resolve_group(Group, Kind) -> + OwnerHd = maps:get(owner, hd(Group)), + NearestLevel = lists:takewhile( + fun(I) -> maps:get(owner, I) =:= OwnerHd end, Group), + Winner = pick_winner(NearestLevel), + Losers = Group -- [Winner], + {Demoted, Dropped} = lists:partition( + fun(L) -> maps:get(real_tpl, Winner) andalso maps:get(real_tpl, L) end, + Losers), + MergedMax = lists:foldl( + fun(I, Acc) -> merge_max(Acc, maps:get(max, I)) end, + maps:get(max, Winner), Dropped), + WinnerOut = rebuild(Winner, Kind, {maps:get(min, Winner), MergedMax}, + keep_mode), + DemotedOuts = [ rebuild(D, Kind, {maps:get(min, D), maps:get(max, D)}, + propose) || D <- Demoted ], + [WinnerOut | DemotedOuts]. + +%% pick_winner([item()]) -> item() +%% Highest mode priority; ties keep the earliest (arc order). +pick_winner([H | T]) -> + lists:foldl(fun(C, Best) -> + case priority(maps:get(mode, C)) > priority(maps:get(mode, Best)) of + true -> C; + false -> Best + end + end, H, T). + +priority(mandatory) -> 3; +priority(auto) -> 2; +priority(propose) -> 1; +priority(_) -> 0. + +%% merge_max(MaxA, MaxB) -> Max (unbounded dominates) +merge_max(unbounded, _) -> unbounded; +merge_max(_, unbounded) -> unbounded; +merge_max(A, B) -> max(A, B). + +%% rebuild(item(), Kind, {Min, Max}, keep_mode | propose) -> Pair +rebuild(Item, composition, Mult, ModeSpec) -> + {RuleNode, Deploy} = maps:get(pair, Item), + {RuleNode, set_mode(Deploy#{multiplicity => Mult}, ModeSpec)}; +rebuild(Item, connection, Mult, ModeSpec) -> + {Rule, Deploy, Spec} = maps:get(pair, Item), + {Rule, set_mode(Deploy#{multiplicity => Mult}, ModeSpec), Spec}. + +set_mode(Deploy, keep_mode) -> Deploy; +set_mode(Deploy, propose) -> Deploy#{mode => propose}. +``` + +- [ ] **Step 5: Run the new tests and the full graphdb suites** + +Run: +``` +make test-ct-parallel FILTER=graphdb_rules FILTER=graphdb_instance +``` +Expected: PASS — all five `b5_comp_*` cases plus the Task 1 cases and every +pre-existing case. (The Task 1 connection-firing paths still see the additive +pass-through, so connection tests are unchanged.) + +- [ ] **Step 6: Commit** + +```bash +git add apps/graphdb/src/graphdb_rules.erl apps/graphdb/test/graphdb_rules_SUITE.erl +git commit -m "F4 B5 T2: default composition resolution (group, shadow, merge) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 3: Composition template demotion (B5-D4/D5) + +Make `comp_item` compute `real_tpl` properly so a loser is demoted to `propose` +(keeping its own range) when **both** the winner and that loser carry a real +(non-default) template; a mixed pair still drops the loser. + +**Files:** +- Modify: `apps/graphdb/src/graphdb_rules.erl` +- Test: `apps/graphdb/test/graphdb_rules_SUITE.erl` + +- [ ] **Step 1: Write the failing tests (both-real demote; mixed drop)** + +To create a real template, pass an explicit `TemplateNref` to +`create_composition_rule/7` that differs from the owning class's default +template. `graphdb_class:default_template/1` returns `{ok, DT}`; use a *second* +class's default template as the "non-default" template for the rule (any +template nref ≠ the owning class's default counts as real per B5-D5). + +```erlang +%%----------------------------------------------------------------------------- +%% Both-real-template demote (B5-D4): Car@tplA auto Engine; Vehicle@tplB +%% mandatory Engine. Winner = Car's auto Engine (fires); loser re-emitted as +%% an independent propose keeping its own {1,2} range. +%%----------------------------------------------------------------------------- +b5_comp_both_real_template_demote(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + %% real (non-default) templates: borrow other classes' default templates + {ok, TplA} = graphdb_class:default_template(Engine), + {ok, TplB} = graphdb_class:default_template(Vehicle), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CE", Car, Engine, auto, {1, 1}, TplA), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 2}, TplB), + {ok, Pairs} = resolve_comp(Car), + ?assertEqual(2, length(Pairs)), + Modes = [maps:get(mode, D) || {_R, D} <- Pairs], + ?assertEqual([auto, propose], Modes), + %% the demoted propose keeps its OWN {1,2}, not merged + [{_, _}, {_, PropDep}] = Pairs, + ?assertEqual({1, 2}, maps:get(multiplicity, PropDep)). + +%%----------------------------------------------------------------------------- +%% Mixed template drop (B5-D4): only the nearest carries a real template; the +%% ancestor uses its default. Loser dropped, greatest-Max merged, no propose. +%%----------------------------------------------------------------------------- +b5_comp_mixed_template_drop(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, TplA} = graphdb_class:default_template(Engine), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CE", Car, Engine, auto, {1, 1}, TplA), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 2}), %% default tpl + {ok, [{_R, Dep}]} = resolve_comp(Car), + ?assertEqual(auto, maps:get(mode, Dep)), + ?assertEqual({1, 2}, maps:get(multiplicity, Dep)). %% greatest Max merged +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: +``` +./rebar3 ct --suite apps/graphdb/test/graphdb_rules_SUITE --case b5_comp_both_real_template_demote +``` +Expected: FAIL — `real_tpl` is hard-coded `false`, so the loser is dropped +instead of demoted (one pair, not two). + +- [ ] **Step 3: Compute `real_tpl` in `comp_item`; add `real_template/3`** + +In `graphdb_rules.erl`, replace `comp_item/4` so it passes `TplAttr` and the +owner into the template check: + +```erlang +comp_item({RuleNode, Deploy} = Pair, ChildAttr, TplAttr, AppliedBy) -> + {Min, Max} = maps:get(multiplicity, Deploy, {1, 1}), + Owner = owning_class(RuleNode, AppliedBy), + #{pair => Pair, + ref => content_avp_value(RuleNode, ChildAttr), + char => undefined, + mode => maps:get(mode, Deploy, mandatory), + min => Min, + max => Max, + owner => Owner, + real_tpl => real_template(RuleNode, TplAttr, Owner)}. +``` + +Add `real_template/3` in the B5 section: + +```erlang +%% real_template(RuleNode, TplAttr, OwningClass) -> boolean() +%% True iff the rule carries a content template_nref AVP whose value differs from +%% its owning class's default template (B5-D5). Absent template_nref -> false. +real_template(RuleNode, TplAttr, OwningClass) -> + case content_avp_value(RuleNode, TplAttr) of + undefined -> + false; + TplNref -> + case graphdb_class:default_template(OwningClass) of + {ok, Default} -> TplNref =/= Default; + _ -> true + end + end. +``` + +- [ ] **Step 4: Run the new tests and the rules suite** + +Run: +``` +make test-ct-parallel FILTER=graphdb_rules +``` +Expected: PASS — both new cases plus all Task 2 cases (default-template fixtures +still drop their losers because `real_template/3` returns `false` for them). + +- [ ] **Step 5: Commit** + +```bash +git add apps/graphdb/src/graphdb_rules.erl apps/graphdb/test/graphdb_rules_SUITE.erl +git commit -m "F4 B5 T3: composition template demotion to propose (B5-D4/D5) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 4: Default connection resolution (B5-D1 connection + same disposition) + +Replace the connection pass-through with the real algorithm: group by +`characterization` + descendant `target_class`, shadow / merge / demote exactly +like composition (the shared `resolve_group`/grouping helpers already handle +both kinds). + +**Files:** +- Modify: `apps/graphdb/src/graphdb_rules.erl` +- Test: `apps/graphdb/test/graphdb_rules_SUITE.erl` + +- [ ] **Step 1: Write the failing tests (connection target shadow; additive)** + +Helper `make_rel_pair/2` exists (`graphdb_rules_SUITE.erl:1401`) and returns +`{Char, Recip}`. + +```erlang +%%----------------------------------------------------------------------------- +%% Connection target shadow (B5-D1): Car owns Garage (is-a Building); Vehicle +%% owns Building, same `owns' characterization. One winner -> Garage. +%%----------------------------------------------------------------------------- +b5_conn_target_shadow(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Building} = graphdb_class:create_class("Building", 3), + {ok, Garage} = graphdb_class:create_class("Garage", Building), + {Owns, Owned} = make_rel_pair("owns", "owned_by"), + {ok, _} = graphdb_rules:create_connection_rule( + environment, "CG", Car, Owns, Owned, Garage, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_connection_rule( + environment, "VB", Vehicle, Owns, Owned, Building, mandatory, {1, 1}), + {ok, [{_R, _Dep, Spec}]} = resolve_conn(Car), + ?assertEqual(Garage, maps:get(target_class, Spec)). + +%%----------------------------------------------------------------------------- +%% Connection additive (unrelated targets, same characterization): both survive. +%%----------------------------------------------------------------------------- +b5_conn_additive_unrelated(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Building} = graphdb_class:create_class("Building", 3), + {ok, Boat} = graphdb_class:create_class("Boat", 3), + {Owns, Owned} = make_rel_pair("owns", "owned_by"), + {ok, _} = graphdb_rules:create_connection_rule( + environment, "CB", Car, Owns, Owned, Boat, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_connection_rule( + environment, "VB", Vehicle, Owns, Owned, Building, mandatory, {1, 1}), + {ok, Pairs} = resolve_conn(Car), + ?assertEqual(2, length(Pairs)). +``` + +Add a `resolve_conn/1` helper alongside `resolve_comp/1`: + +```erlang +%% resolve_conn(ClassNref) -> {ok, [{#node{}, Deploy, Spec}]} +resolve_conn(ClassNref) -> + {ok, Specs} = graphdb_rules:effective_connection_rules(environment, ClassNref), + Resolver = graphdb_rules:default_conflict_resolver(), + {ok, Resolver(#{kind => connection, rules => Specs, class_nref => ClassNref})}. +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: +``` +./rebar3 ct --suite apps/graphdb/test/graphdb_rules_SUITE --case b5_conn_target_shadow +``` +Expected: FAIL — the connection clause is still the additive pass-through, so +both rules survive (two pairs, not one). + +- [ ] **Step 3: Implement the connection clause + `conn_item/3`** + +In `graphdb_rules.erl`, replace the connection clause of `resolve_conflicts/4`: + +```erlang +resolve_conflicts(#{kind := connection, rules := Specs}, _ChildAttr, TplAttr, + AppliedBy) -> + Items = [conn_item(S, TplAttr, AppliedBy) || S <- Specs], + Groups = assign_groups(Items, connection), + lists:flatmap(fun(G) -> resolve_group(G, connection) end, Groups); +``` + +(Keep the composition clause unchanged above it.) Add `conn_item/3`: + +```erlang +%% conn_item({Rule, Deploy, Spec}, TplAttr, AppliedBy) -> item() +%% target_class and characterization come from the connection Spec (no child +%% attr needed); real_tpl re-derives the owning (source) class via applied_by. +conn_item({Rule, Deploy, Spec} = Pair, TplAttr, AppliedBy) -> + {Min, Max} = maps:get(multiplicity, Deploy, {1, 1}), + Owner = owning_class(Rule, AppliedBy), + #{pair => Pair, + ref => maps:get(target_class, Spec), + char => maps:get(characterization, Spec), + mode => maps:get(mode, Deploy, mandatory), + min => Min, + max => Max, + owner => Owner, + real_tpl => real_template(Rule, TplAttr, Owner)}. +``` + +- [ ] **Step 4: Run the new tests and the full graphdb suites** + +Run: +``` +make test-ct-parallel FILTER=graphdb_rules FILTER=graphdb_instance +``` +Expected: PASS — connection grouping resolves; the B4 connection-firing instance +tests still pass (a single connection rule per class is a group of one → +unchanged; multi-rule conflicts now resolve). + +- [ ] **Step 5: Commit** + +```bash +git add apps/graphdb/src/graphdb_rules.erl apps/graphdb/test/graphdb_rules_SUITE.erl +git commit -m "F4 B5 T4: default connection resolution (B5-D1 connection) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 5: End-to-end firing proofs, custom-resolver override, and docs + +Prove resolution end-to-end through `create_instance` (the Cell/Nucleus firing +flip and a demote-surfaces-as-proposed case), prove the seam is overridable with +a custom resolver, and update the docs. + +**Files:** +- Test: `apps/graphdb/test/graphdb_instance_SUITE.erl` +- Modify: `apps/graphdb/CLAUDE.md`; `docs/Architecture.md` (only the + `create_instance` contract line, if present) + +- [ ] **Step 1: Write the failing firing + override tests** + +Add to `apps/graphdb/test/graphdb_instance_SUITE.erl` and register in `all/0`. + +```erlang +%%----------------------------------------------------------------------------- +%% Firing flip (B5-D2 at firing time): Cell mandates Nucleus (mandatory) and +%% proposes Nucleus. Under B5 only ONE Nucleus is minted (mandatory wins). +%%----------------------------------------------------------------------------- +b5_firing_same_level_mode_priority(_Config) -> + {ok, Cell} = graphdb_class:create_class("Cell", 3), + {ok, Nucleus} = graphdb_class:create_class("Nucleus", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CN-prop", Cell, Nucleus, propose, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CN-mand", Cell, Nucleus, mandatory, {1, 1}), + {ok, Root, Report} = graphdb_instance:create_instance("c1", Cell, 5), + {ok, Kids} = graphdb_instance:children(Root), + ?assertEqual(1, length(Kids)), %% exactly one Nucleus minted + #{fired := 1, proposed := 0} = + maps:with([fired, proposed], graphdb_instance:summarize(Report)). + +%%----------------------------------------------------------------------------- +%% Cross-level shadow at firing time: Car + Vehicle both mandate Engine -> one +%% Engine minted (not two). +%%----------------------------------------------------------------------------- +b5_firing_cross_level_shadow(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CE", Car, Engine, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 1}), + {ok, Root, _Report} = graphdb_instance:create_instance("car", Car, 5), + {ok, Kids} = graphdb_instance:children(Root), + ?assertEqual(1, length(Kids)). + +%%----------------------------------------------------------------------------- +%% Custom resolver overrides the seam: a pure-additive resolver makes Car + +%% Vehicle both fire (two Engines), proving the policy is caller-overridable. +%%----------------------------------------------------------------------------- +b5_custom_resolver_pure_additive(_Config) -> + {ok, Vehicle} = graphdb_class:create_class("Vehicle", 3), + {ok, Car} = graphdb_class:create_class("Car", Vehicle), + {ok, Engine} = graphdb_class:create_class("Engine", 3), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "CE", Car, Engine, mandatory, {1, 1}), + {ok, _} = graphdb_rules:create_composition_rule( + environment, "VE", Vehicle, Engine, mandatory, {1, 1}), + Additive = fun(#{rules := R}) -> R end, + Conn = fun(_Ctx) -> defer end, + {ok, Root, _Report} = + graphdb_instance:create_instance("car", Car, 5, Conn, Additive), + {ok, Kids} = graphdb_instance:children(Root), + ?assertEqual(2, length(Kids)). %% additive: both fire +``` + +- [ ] **Step 2: Run the tests to verify they pass (resolution already implemented)** + +Run: +``` +./rebar3 ct --suite apps/graphdb/test/graphdb_instance_SUITE --case b5_firing_same_level_mode_priority +./rebar3 ct --suite apps/graphdb/test/graphdb_instance_SUITE --case b5_firing_cross_level_shadow +./rebar3 ct --suite apps/graphdb/test/graphdb_instance_SUITE --case b5_custom_resolver_pure_additive +``` +Expected: PASS — Tasks 2–4 implemented the resolution; these are end-to-end +confirmations through `create_instance` plus the override seam. + +(If any fail, the defect is in Tasks 2–4's wiring, not in new production code — +fix there and re-run.) + +- [ ] **Step 3: Update docs** + +In `apps/graphdb/CLAUDE.md`, in the `graphdb_instance` API bullet, note the new +arity and the conflict resolver, e.g. extend the `create_instance` line: + +``` +- `create_instance/3,4,5` (name, class_nref, compositional_parent_nref + [, connection_resolver [, conflict_resolver]]) — ... `/5` threads a B5 + **conflict resolver** (`fun((#{kind, rules, class_nref}) -> [Pair])`); `/3` + and `/4` inject the built-in `graphdb_rules:default_conflict_resolver/0`, + which shadows conflicting inherited rules, merges multiplicity (nearest Min, + greatest Max), and demotes both-real-template losers to `propose` (F4 B5). +``` + +In the `graphdb_rules` section of `apps/graphdb/CLAUDE.md`, add a bullet for +`default_conflict_resolver/0` and `plan_composition_firing/3`, and update the +phase status line from `...+ B4` to `...+ B4 + B5`. Do the same to the file +header table row for `graphdb_rules.erl` and the "NYI Status" / "Remaining Work" +paragraphs (drop "Phase B5 (precedence)" from outstanding; leave Phases C–F). + +In the project root `CLAUDE.md` "Known Incomplete Areas" bullet for +`graphdb_rules`, move "conflict precedence" from outstanding to implemented. + +`docs/Architecture.md`: update only if it states the `create_instance` arity or +the rules-engine phase status; B5 adds no schema/supervision change, so most of +it is untouched. `docs/diagrams/ontology-tree.md`: **no change** (B5 seeds +nothing). + +- [ ] **Step 4: Run the full test suite (both suites + the whole project)** + +Run: +``` +make test-ct-parallel +./rebar3 eunit +``` +Expected: every CT suite and all EUnit tests green; clean compile, zero +warnings. + +- [ ] **Step 5: Commit** + +```bash +git add apps/graphdb/test/graphdb_instance_SUITE.erl apps/graphdb/CLAUDE.md \ + CLAUDE.md docs/Architecture.md +git commit -m "F4 B5 T5: end-to-end firing proofs, custom-resolver override, docs + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Self-review (run after the plan is written, before execution) + +- **Spec coverage:** + - B5-D1 grouping/descendant match — T2 (composition), T4 (connection). + - B5-D2 nearest winner + mode-priority tie — T2 (`b5_comp_*`), T5 (`b5_firing_same_level_mode_priority`). + - B5-D3 nearest-Min/greatest-Max — T2 (`b5_comp_max_merge_unbounded`). + - B5-D4/D5 demote vs drop, real-template — T3. + - B5-D6 resolver owned by instance, threaded as `/5`, default in rules, + applied per cascade level (composition) and per node (connection) — T1. + - B5-D7 integration-free (demoted entries flow through B3/B4 propose) — proven + by T5 firing (proposed outcomes surface via existing machinery). + - §6 worked examples — every row has a test (cross-level, descendant, + additive, max-merge-unbounded, same-level tie, both-real demote, mixed drop, + connection target shadow, connection additive, custom override). + - §1.3 B1 contract preserved — `/2` routed through an identity resolver; no + `effective_rules_for_class/2` change. + +- **Edge cases (§5):** "matches two unrelated winners → joins only the nearest" + is enforced by `find_group` returning the *first* matching group. "Bad/unknown + nref" is unchanged (`effective_rules` returns `[]` → empty resolver input → + `[]`). Custom-resolver malformed output is out of scope (caller owns it). + +- **Type/name consistency:** the item map keys (`pair, ref, char, mode, min, + max, owner, real_tpl`) are produced by `comp_item`/`conn_item` and consumed by + `assign_groups`/`resolve_group`/`pick_winner`/`merge_max`/`rebuild` + identically. `rebuild` reconstructs the exact `{RuleNode, Deploy}` / + `{Rule, Deploy, Spec}` shapes the consumers expect. `default_conflict_resolver/0` + closure shape matches both apply points + (`#{kind, rules, class_nref}` → `[Pair]`). + +- **Deadlock check:** the default resolver closure calls only `graphdb_class` + (different gen_server) and Mnesia dirty reads — never the `graphdb_rules` + gen_server. Safe in both the rules process (composition) and the instance + process (connection).