Skip to content
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 43 additions & 43 deletions README.md

Large diffs are not rendered by default.

29 changes: 21 additions & 8 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 25 additions & 13 deletions apps/graphdb/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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)
Expand All @@ -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`).
Expand Down Expand Up @@ -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
Expand All @@ -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.
40 changes: 31 additions & 9 deletions apps/graphdb/src/graphdb_instance.erl
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
%% Creators
create_instance/3,
create_instance/4,
create_instance/5,
add_relationship/4,
add_relationship/5,
add_relationship/6,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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};

Expand Down Expand Up @@ -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} ->
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading