diff --git a/.drafts/convenience-combine.md b/.drafts/convenience-combine.md
deleted file mode 100644
index 296ac4b..0000000
--- a/.drafts/convenience-combine.md
+++ /dev/null
@@ -1,58 +0,0 @@
-
-
-# Drafts — Artefact.combine convenience wrapper
-
-Drafts only. Branch `10-convenience-combine`. Per `.claude/settings.json` no
-`git commit / push / add` was run.
-
----
-
-## Draft commit message — `artefact`
-
-```
-feat(artefact): pipeline-friendly combine/3
-
-Artefact.combine(heart, other, opts \\ []) is a convenience wrapper around
-Artefact.Binding.find/2 + Artefact.harmonise/4 designed for pipelines —
-the heart flows through the pipe as the first argument:
-
- me_knowing
- |> Artefact.combine(me_valuing)
- |> Artefact.combine(me_being)
- |> Artefact.combine(me_doing, title: "MeMind", description: "Mind of Me")
-
-Bindings are auto-found via shared uuids; opts pass through to harmonise
-for :title and :base_label overrides. :description is patched onto the
-result (since harmonise itself does not yet honour :description in opts).
-Raises MatchError when there are no shared nodes.
-
-Provenance: :harmonised, with the calling module. Combine is sugar over
-harmonise — the underlying operation IS a harmonise, so the trace stays
-honest. The convenience is the binding-find and the heart-first arg order.
-```
-
-## Notes for the next yarn
-
-- **harmonise/compose could honour :description in opts.** Right now combine
- patches it post-hoc. If you later extend `do_harmonise/5` and `do_compose/4`
- to read `:description` from opts and pass it into `build/1`, combine can
- drop the patch and just delegate. Worth a small follow-up issue, not part
- of this branch.
-
-- **Portmanteau base_label grows.** Each combine step concatenates the
- heart and other base_labels. Through a five-step pipeline that becomes a
- long word like `KnowingValuingBeingKnowingMoreDoing`. The final step's
- opts can rename it (`base_label: "MeMind"`), which is what the example
- in the docstring shows. Worth a note in any usage example.
-
-- **No `combine!` variant.** combine raises MatchError on no bindings, which
- matches the user's original livebook helper. If we later want a
- `combine_with/3` that explicitly accepts `inject:` bindings, that's a new
- function — keep combine as the simple shared-uuid case.
-
----
-
-*Held in the commons.*
diff --git a/.drafts/mermaid-export.md b/.drafts/mermaid-export.md
deleted file mode 100644
index 1ac5cb3..0000000
--- a/.drafts/mermaid-export.md
+++ /dev/null
@@ -1,156 +0,0 @@
-
-
-# Drafts — Mermaid export for Artefact + ArtefactKino
-
-These are drafts only. Per `.claude/settings.json` no `git commit / push / add`
-was run; per AGENTS.md the work belongs to the relation, not to the agent.
-Review, edit, and use as you see fit.
-
----
-
-## Draft commit message — `artefact`
-
-```
-feat(artefact): add Mermaid export
-
-Artefact.Mermaid.export/2 emits Mermaid `graph` source from an
-%Artefact{}, sitting alongside Artefact.Cypher and Artefact.Arrows
-as a third derived form.
-
-- legacy `graph` syntax for broad renderer reach (GitHub, Notion,
- mdBook, Livebook)
-- nodes render as circles (`id(("..."))`) — property-graph
- convention, matches the vis-network ellipses in the heartside
- panel
-- node label mirrors the ArtefactKino vis-network panel:
- name (or id) on top, semantic labels joined with a space below,
- separated by
-- base_label is dropped from per-node labels at output time,
- consistent with the Cypher exporter's effective_labels rule
-- artefact.title becomes Mermaid front-matter `title:` plus an
- `accTitle:` line for screen readers; nil title omits both
-- :direction option (:LR default, :RL :TB :BT :TD)
-- escape rules:
- * double quote in node label -> "
- * pipe in edge label -> |
- * YAML-unsafe chars in title -> double-quoted scalar with \"
- and \\ escaped
-
-Lossy: position, style, and properties beyond `name` are not
-represented — Mermaid is a render concern, not a persistence form.
-
-Fixture added at test/data/us_two/mermaid.mmd; ExUnit cases cover
-the us_two round-trip, direction option, both escapes, the empty
-graph, and the no-name fallback to node id.
-```
-
-## Draft commit message — `artefact` (branch `9-support-description`)
-
-```
-feat(artefact): optional description field, surfaced as Mermaid accDescr
-
-%Artefact{} now carries an optional :description alongside :title.
-It defaults to nil and is only emitted into Mermaid when set.
-
-- new field on the struct + typespec
-- Artefact.new accepts description: passthrough; macro docstring updated
-- Artefact.Arrows round-trips description as a top-level "description"
- key (peer of "title", may be null)
-- Artefact.Mermaid emits accDescr (inline form for single-line,
- block form `accDescr { ... }` when the description contains
- newlines); placed between accTitle and the node lines
-- ArtefactKino inspector grows a `description` row on the Artefact tab
-
-AGENTS.md schema updated.
-
-compose/harmonise are deliberately left to default description to nil —
-combining two descriptions is a judgement call we don't want the
-library making silently. Pass description: in opts when needed.
-```
-
-## Draft commit message — `artefact_kino`
-
-```
-feat(artefact_kino): MERMAID button on the export panel
-
-Adds Artefact.Mermaid.export/1 alongside CREATE / MERGE / JSON
-in the export panel of the three-panel widget. Pure text output
-for now — copy with click-to-select, same as the existing buttons.
-
-Live Mermaid rendering via mermaid.js was discussed but deferred —
-keeping the panel symmetric with the other text exports for this
-pass.
-```
-
----
-
-## Draft issue — *Live Mermaid rendering in ArtefactKino*
-
-**Title:** `artefact_kino: render Mermaid live in the export panel`
-
-**Body:**
-
-> Today the MERMAID button shows the source as text, the same as
-> CREATE / MERGE / JSON. Useful for copying out, less useful as a
-> second view of the graph next to the vis-network panel.
->
-> A follow-up would load mermaid.js from CDN (matching the existing
-> vis-network bootstrap) and render the diagram inside the export
-> panel, with a small toggle to flip back to source view.
->
-> ### Why it might be worth doing
->
-> - vis-network is force-directed; Mermaid layouts are deterministic.
-> Two renderings of the same artefact, side by side, can show
-> structure that one alone does not.
-> - Mermaid is what most readers paste into a doc. Seeing it render
-> the same way it will appear in the doc closes a feedback loop.
->
-> ### Why we deferred it
->
-> - The current panel is symmetric across CREATE / MERGE / JSON / MERMAID
-> as text. Adding a render mode for one of them breaks that symmetry.
-> - mermaid.js is a heavier CDN load than vis-network. Worth measuring
-> before adding.
-> - The vis-network panel already does live rendering — the question is
-> whether a second live view earns its keep.
->
-> ### Sketch
->
-> - keep the `MERMAID` button
-> - add a small `source / rendered` switch that only appears when
-> MERMAID is selected
-> - bootstrap mermaid.js the same way `loadVis()` bootstraps vis-network
-> - render into a `
`
-
----
-
-## Draft issue — *Mermaid fixtures for the remaining test data sets*
-
-**Title:** `artefact: add mermaid.mmd fixtures for artefact_*, artefactory, lexical_categories, create_merge`
-
-**Body:**
-
-> `test/data/us_two/mermaid.mmd` is in. The other fixture folders
-> (`artefact`, `artefact_combine`, `artefact_harmonise`, `artefactory`,
-> `lexical_categories`, `create_merge`) all have `arrows.json` plus
-> Cypher fixtures but no Mermaid one yet.
->
-> Either:
-> 1. Generate fixtures by running `Artefact.Mermaid.export/1` once
-> against each, eyeball the output, commit. (Risk: locks in
-> whatever the implementation does today.)
-> 2. Hand-author each one, then assert the export matches. (Slower,
-> but each fixture acts as a spec for what the diagram should
-> say to a reader.)
->
-> Recommendation: option 2 for `artefact` and `artefactory` (the
-> self-describing artefacts — the fixture *is* the documentation),
-> option 1 for the rest.
-
----
-
-*The artefact belongs to the edge.*
diff --git a/.gitignore b/.gitignore
index 2a4c8e6..0371853 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,7 +19,7 @@ artefactory-*.tar
# Temporary files, for example, from tests.
/tmp/
-
+/drafts
/.elixir_ls
.DS_Store
diff --git a/artefact/CHANGELOG.md b/artefact/CHANGELOG.md
index 558b582..4bec341 100644
--- a/artefact/CHANGELOG.md
+++ b/artefact/CHANGELOG.md
@@ -5,6 +5,10 @@ SPDX-License-Identifier: MIT
# Changelog
+## 0.1.4 — 2026-05-05
+
+- `Artefact.graft/3` — pipeline-friendly convenience for extending an artefact with new nodes and relationships declared inline (same shape as `Artefact.new`); every node in args MUST carry `:uuid` (no auto-find — uuid is the binding); nodes whose uuid lives in left bind to it (labels unioned, properties merged left-wins), nodes with new uuids are added; opts honour `:title` and `:description`; raises `ArgumentError` for missing uuid, duplicate keys, or relationship referencing an unknown key; records `:grafted` provenance source
+
## 0.1.3 — 2026-04-30
- `Artefact.Mermaid.export/2` — derives Mermaid `graph` source from an `%Artefact{}`, alongside `Artefact.Cypher` and `Artefact.Arrows`; nodes render as circles, `:direction` option for `:LR`, `:RL`, `:TB`, `:BT`, `:TD`
diff --git a/artefact/README.md b/artefact/README.md
index 7251dc6..dd42bef 100644
--- a/artefact/README.md
+++ b/artefact/README.md
@@ -76,19 +76,44 @@ Artefact.Cypher.create(us_two)
Artefact.Arrows.to_json(us_two)
```
-## Combining Artefacts
+## Combining and Extending Artefacts
```elixir
# compose — disjoint union, nodes remain independent
combined = Artefact.compose(a1, a2)
-# harmonise — merge nodes bound by shared uuid
-# lower uuid wins identity, labels are unioned, left (heartside) wins on property conflict
+# combine — pipeline-friendly union; bindings auto-found via shared uuid.
+# Delegates to harmonise. Raises MatchError if no shared nodes.
+my_knowing
+|> Artefact.combine(my_valuing)
+|> Artefact.combine(my_being)
+|> Artefact.combine(my_doing, title: "MeMind", description: "Mind of Me")
+
+# harmonise — union via declared bindings.
+# Lower uuid wins identity, labels are unioned, left wins on property conflict.
{:ok, bindings} = Artefact.Binding.find(a1, a2)
harmonised = Artefact.harmonise(a1, a2, bindings)
+
+# graft — extend an existing artefact inline with new nodes and
+# relationships. args matches Artefact.new's inline shape, but every
+# node MUST carry :uuid (no auto-find — uuid is the binding).
+# Nodes whose uuid lives in left bind to it (labels unioned, properties
+# merged left-wins). Nodes with new uuids are added.
+me_mind
+|> Artefact.graft(
+ [
+ nodes: [
+ {:me, [uuid: "019ddb71-c70b-7b3e-83b1-58f4d0be2852"]}, # bind-only
+ {:stewardship, [labels: ["Knowing"], uuid: "019df318-698c-77d6-bc7b-ea041a019a7f"]}
+ ],
+ relationships: [[from: :me, type: "KNOWING", to: :stewardship]]
+ ],
+ title: "MeMind + Stewardship",
+ description: "Stewardship grafted onto MeMind."
+ )
```
-Provenance is recorded automatically — every artefact carries metadata describing how it was created, including the module it was built in and, for harmonised artefacts, the title, base_label and uuid of each source.
+Provenance is recorded automatically — every artefact carries metadata describing how it was created, including the calling module and, for derived artefacts, a summary of each source.
## Importing from Arrows JSON
diff --git a/artefact/lib/artefact.ex b/artefact/lib/artefact.ex
index 94ab2b1..c30e5a8 100644
--- a/artefact/lib/artefact.ex
+++ b/artefact/lib/artefact.ex
@@ -7,11 +7,42 @@ defmodule Artefact do
expressed as a property graph.
The canonical form is the `%Artefact{}` struct. Arrows JSON and Cypher are
- derived representations: JSON for interchange and visual editing, Cypher for
- persistence.
+ derived representations: JSON for interchange and visual editing, Cypher
+ for persistence.
+
+ ## Operations
+
+ * `new/1` — build an artefact, inline (`:nodes` + `:relationships`) or
+ from a pre-built `%Artefact.Graph{}`.
+ * `compose/3` — concatenate two artefacts; nodes remain disjoint.
+ * `combine/3` — pipeline-friendly union; bindings auto-found via shared
+ uuid; delegates to `harmonise/4`.
+ * `harmonise/4` — union via declared bindings; lower uuid wins identity,
+ labels are unioned, left wins on property conflict.
+ * `graft/3` — pipeline-friendly extension; integrates inline `args`
+ (same shape as `new`'s inline form, but every node MUST carry `:uuid`)
+ into an existing artefact.
+
+ Every operation records its lineage in the result's `metadata.provenance`.
+
+ ## Exporting
+
+ * `Artefact.Arrows` — round-trip with [arrows.app](https://arrows.app)
+ via `from_json/2`, `from_json!/2`, `to_json/1`.
+ * `Artefact.Cypher` — derive Cypher (CREATE or MERGE) for Neo4j
+ persistence, with parameterised variants for driver use.
"""
- defstruct [:id, :uuid, :title, :description, :base_label, :style, metadata: %{}, graph: %Artefact.Graph{}]
+ defstruct [
+ :id,
+ :uuid,
+ :title,
+ :description,
+ :base_label,
+ :style,
+ metadata: %{},
+ graph: %Artefact.Graph{}
+ ]
@type t :: %__MODULE__{
id: String.t(),
@@ -36,14 +67,20 @@ defmodule Artefact do
"""
defmacro new(attrs \\ []) do
caller = __CALLER__.module
- caller_name = caller && (caller |> Module.split() |> List.last())
+ caller_name = caller && caller |> Module.split() |> List.last()
default_base_label = caller_name && String.replace(caller_name, ~r/[^A-Za-z0-9]/, "")
+
quote do
- attrs = unquote(attrs)
- metadata = %{provenance: %{source: :struct, module: unquote(caller)}}
- title = Keyword.get(attrs, :title, unquote(caller_name))
+ attrs = unquote(attrs)
+ metadata = %{provenance: %{source: :struct, module: unquote(caller)}}
+ title = Keyword.get(attrs, :title, unquote(caller_name))
base_label = Keyword.get(attrs, :base_label, unquote(default_base_label))
- Artefact.build([{:title, title}, {:base_label, base_label}, {:metadata, metadata} | Keyword.drop(attrs, [:title, :base_label, :metadata])])
+
+ Artefact.build([
+ {:title, title},
+ {:base_label, base_label},
+ {:metadata, metadata} | Keyword.drop(attrs, [:title, :base_label, :metadata])
+ ])
end
end
@@ -59,6 +96,7 @@ defmodule Artefact do
"""
defmacro compose(a1, a2, opts \\ []) do
caller = __CALLER__.module
+
quote do
Artefact.do_compose(unquote(a1), unquote(a2), unquote(opts), unquote(caller))
end
@@ -67,11 +105,28 @@ defmodule Artefact do
@doc false
def do_compose(%__MODULE__{} = a1, %__MODULE__{} = a2, opts, caller) do
base_label = Keyword.get(opts, :base_label, portmanteau(a1.base_label, a2.base_label))
- title = Keyword.get(opts, :title, base_label)
- graph = merge_graphs(a1.graph, a2.graph)
- metadata = %{provenance: %{source: :composed, module: caller,
- left: %{title: a1.title, base_label: a1.base_label, uuid: a1.uuid, provenance: Map.get(a1.metadata, :provenance)},
- right: %{title: a2.title, base_label: a2.base_label, uuid: a2.uuid, provenance: Map.get(a2.metadata, :provenance)}}}
+ title = Keyword.get(opts, :title, base_label)
+ graph = merge_graphs(a1.graph, a2.graph)
+
+ metadata = %{
+ provenance: %{
+ source: :composed,
+ module: caller,
+ left: %{
+ title: a1.title,
+ base_label: a1.base_label,
+ uuid: a1.uuid,
+ provenance: Map.get(a1.metadata, :provenance)
+ },
+ right: %{
+ title: a2.title,
+ base_label: a2.base_label,
+ uuid: a2.uuid,
+ provenance: Map.get(a2.metadata, :provenance)
+ }
+ }
+ }
+
build([{:title, title}, {:base_label, base_label}, {:graph, graph}, {:metadata, metadata}])
end
@@ -98,6 +153,7 @@ defmodule Artefact do
"""
defmacro combine(heart, other, opts \\ []) do
caller = __CALLER__.module
+
quote do
Artefact.do_combine(unquote(heart), unquote(other), unquote(opts), unquote(caller))
end
@@ -114,6 +170,209 @@ defmodule Artefact do
end
end
+ @doc """
+ Graft `args` onto `left`, integrating new nodes and relationships
+ declared inline (same shape as `Artefact.new` accepts) without creating
+ a second artefact.
+
+ Designed for pipelines after a series of `combine`s — `args` flows in as
+ the second argument, with the result's `:title` and `:description` named
+ in `opts`:
+
+ our_shells_artefact
+ |> Artefact.combine(our_manifesto_artefact)
+ |> Artefact.graft(args, title: "Our Shells and Manifesto",
+ description: "Our Shells and Manifesto shape our Association Knowing.")
+
+ ## args
+
+ A keyword list with `:nodes` and `:relationships`, identical in shape to
+ what `Artefact.new` accepts inline — except that **every node entry must
+ carry an explicit `:uuid`**. There is no auto-find: the uuid is the
+ binding.
+
+ Each args node either:
+
+ * **Binds** to an existing left node (uuid present in `left.graph.nodes`).
+ Labels are unioned, properties merged with **left winning** on key
+ conflicts. Position is untouched.
+
+ * **Adds** a new node (uuid not in left). Receives a fresh sequential id
+ continuing left's offset.
+
+ Args relationships use args-local atom keys, just like `Artefact.new`.
+ Every key referenced by a relationship must be declared in `args.nodes`.
+
+ ## opts
+
+ Honours `:title` and `:description` only — both name the result. If
+ omitted, `left`'s title and description carry forward. `:base_label` is
+ **not** honoured; the result keeps `left.base_label`.
+
+ ## Raises
+
+ * `ArgumentError` — any args node missing `:uuid`
+ * `ArgumentError` — duplicate keys in `args.nodes`
+ * `ArgumentError` — a relationship references a key not in `args.nodes`
+
+ ## Provenance
+
+ Records `:grafted` with the calling module, a summary of `left`, and
+ `right: %{title: , description: }` — the
+ result's name as provided.
+ """
+ defmacro graft(left, args, opts \\ []) do
+ caller = __CALLER__.module
+
+ quote do
+ Artefact.do_graft(unquote(left), unquote(args), unquote(opts), unquote(caller))
+ end
+ end
+
+ @doc false
+ def do_graft(%__MODULE__{} = left, args, opts, caller) do
+ node_specs = Keyword.get(args, :nodes, [])
+ rel_specs = Keyword.get(args, :relationships, [])
+
+ validate_graft_node_uuids!(node_specs)
+ validate_graft_unique_keys!(node_specs)
+
+ left_by_uuid = Map.new(left.graph.nodes, &{&1.uuid, &1})
+
+ {bind_specs, new_specs} =
+ Enum.split_with(node_specs, fn {_key, node_opts} ->
+ Map.has_key?(left_by_uuid, Keyword.fetch!(node_opts, :uuid))
+ end)
+
+ bind_key_map =
+ Map.new(bind_specs, fn {key, node_opts} ->
+ existing = Map.fetch!(left_by_uuid, Keyword.fetch!(node_opts, :uuid))
+ {key, existing.id}
+ end)
+
+ offset = length(left.graph.nodes)
+
+ {new_nodes, new_key_map} =
+ new_specs
+ |> Enum.with_index(offset)
+ |> Enum.map_reduce(%{}, fn {{key, node_opts}, i}, acc ->
+ id = "n#{i}"
+
+ node = %Artefact.Node{
+ id: id,
+ uuid: Keyword.fetch!(node_opts, :uuid),
+ labels: Keyword.get(node_opts, :labels, []),
+ properties: Keyword.get(node_opts, :properties, %{}),
+ position: Keyword.get(node_opts, :position)
+ }
+
+ {node, Map.put(acc, key, id)}
+ end)
+
+ key_map = Map.merge(bind_key_map, new_key_map)
+
+ validate_graft_rel_keys!(rel_specs, key_map)
+
+ bind_updates =
+ Map.new(bind_specs, fn {_key, node_opts} ->
+ uuid = Keyword.fetch!(node_opts, :uuid)
+ existing = Map.fetch!(left_by_uuid, uuid)
+
+ merged = %{
+ existing
+ | labels: Enum.uniq(existing.labels ++ Keyword.get(node_opts, :labels, [])),
+ properties: Map.merge(Keyword.get(node_opts, :properties, %{}), existing.properties)
+ }
+
+ {uuid, merged}
+ end)
+
+ updated_left_nodes =
+ Enum.map(left.graph.nodes, fn n -> Map.get(bind_updates, n.uuid, n) end)
+
+ rel_offset = length(left.graph.relationships)
+
+ new_rels =
+ rel_specs
+ |> Enum.with_index(rel_offset)
+ |> Enum.map(fn {spec, i} ->
+ %Artefact.Relationship{
+ id: "r#{i}",
+ from_id: Map.fetch!(key_map, Keyword.fetch!(spec, :from)),
+ to_id: Map.fetch!(key_map, Keyword.fetch!(spec, :to)),
+ type: Keyword.fetch!(spec, :type),
+ properties: Keyword.get(spec, :properties, %{})
+ }
+ end)
+
+ relationships = deduplicate_rels(left.graph.relationships, new_rels)
+
+ graph = %Artefact.Graph{
+ nodes: updated_left_nodes ++ new_nodes,
+ relationships: relationships
+ }
+
+ title = Keyword.get(opts, :title, left.title)
+ description = Keyword.get(opts, :description, left.description)
+
+ metadata = %{
+ provenance: %{
+ source: :grafted,
+ module: caller,
+ left: %{
+ title: left.title,
+ base_label: left.base_label,
+ uuid: left.uuid,
+ provenance: Map.get(left.metadata, :provenance)
+ },
+ right: %{title: Keyword.get(opts, :title), description: Keyword.get(opts, :description)}
+ }
+ }
+
+ build([
+ {:title, title},
+ {:description, description},
+ {:base_label, left.base_label},
+ {:graph, graph},
+ {:metadata, metadata}
+ ])
+ end
+
+ defp validate_graft_node_uuids!(node_specs) do
+ Enum.each(node_specs, fn {key, node_opts} ->
+ case Keyword.fetch(node_opts, :uuid) do
+ {:ok, _} -> :ok
+ :error -> raise ArgumentError, "graft: node #{inspect(key)} is missing required :uuid"
+ end
+ end)
+ end
+
+ defp validate_graft_unique_keys!(node_specs) do
+ keys = Enum.map(node_specs, fn {k, _} -> k end)
+ dupes = keys -- Enum.uniq(keys)
+
+ if dupes != [] do
+ raise ArgumentError, "graft: duplicate node keys: #{inspect(Enum.uniq(dupes))}"
+ end
+ end
+
+ defp validate_graft_rel_keys!(rel_specs, key_map) do
+ Enum.each(rel_specs, fn spec ->
+ from = Keyword.fetch!(spec, :from)
+ to = Keyword.fetch!(spec, :to)
+
+ unless Map.has_key?(key_map, from) do
+ raise ArgumentError,
+ "graft: relationship references unknown node key #{inspect(from)} (not in args.nodes)"
+ end
+
+ unless Map.has_key?(key_map, to) do
+ raise ArgumentError,
+ "graft: relationship references unknown node key #{inspect(to)} (not in args.nodes)"
+ end
+ end)
+ end
+
@doc """
Harmonise two artefacts using declared bindings.
@@ -126,8 +385,15 @@ defmodule Artefact do
"""
defmacro harmonise(a1, a2, bindings, opts \\ []) do
caller = __CALLER__.module
+
quote do
- Artefact.do_harmonise(unquote(a1), unquote(a2), unquote(bindings), unquote(opts), unquote(caller))
+ Artefact.do_harmonise(
+ unquote(a1),
+ unquote(a2),
+ unquote(bindings),
+ unquote(opts),
+ unquote(caller)
+ )
end
end
@@ -138,18 +404,20 @@ defmodule Artefact do
end
if a1.base_label != nil and a1.base_label == a2.base_label do
- raise ArgumentError, "cannot harmonise artefacts with the same base_label (#{a1.base_label})"
+ raise ArgumentError,
+ "cannot harmonise artefacts with the same base_label (#{a1.base_label})"
end
base_label = Keyword.get(opts, :base_label, portmanteau(a1.base_label, a2.base_label))
- title = Keyword.get(opts, :title, base_label)
+ title = Keyword.get(opts, :title, base_label)
nodes_a = Map.new(a1.graph.nodes, &{&1.uuid, &1})
nodes_b = Map.new(a2.graph.nodes, &{&1.uuid, &1})
# Resolve each binding: primary (lower uuid) absorbs secondary
{primary_updates, b_id_remap} =
- Enum.reduce(bindings, {%{}, %{}}, fn %Artefact.Binding{uuid_a: ua, uuid_b: ub}, {updates, remap} ->
+ Enum.reduce(bindings, {%{}, %{}}, fn %Artefact.Binding{uuid_a: ua, uuid_b: ub},
+ {updates, remap} ->
node_a = nodes_a[ua]
node_b = nodes_b[ub]
surviving = Artefact.UUID.harmonise(ua, ub)
@@ -157,16 +425,17 @@ defmodule Artefact do
{primary, secondary} =
if surviving == ua, do: {node_a, node_b}, else: {node_b, node_a}
- merged = %{primary |
- labels: Enum.uniq(node_a.labels ++ node_b.labels),
- properties: Map.merge(node_b.properties, node_a.properties)
+ merged = %{
+ primary
+ | labels: Enum.uniq(node_a.labels ++ node_b.labels),
+ properties: Map.merge(node_b.properties, node_a.properties)
}
{Map.put(updates, primary.uuid, merged), Map.put(remap, secondary.id, primary.id)}
end)
bound_uuids_b = MapSet.new(bindings, & &1.uuid_b)
- offset = length(a1.graph.nodes)
+ offset = length(a1.graph.nodes)
# Reindex a2 non-bound nodes to avoid id collision
{b_nodes_reindexed, b_id_remap} =
@@ -178,32 +447,53 @@ defmodule Artefact do
{acc ++ [%{node | id: new_id}], Map.put(remap, node.id, new_id)}
end)
- nodes_from_a = Enum.map(a1.graph.nodes, fn n ->
- Map.get(primary_updates, n.uuid, n)
- end)
+ nodes_from_a =
+ Enum.map(a1.graph.nodes, fn n ->
+ Map.get(primary_updates, n.uuid, n)
+ end)
- rels_from_b = Enum.map(a2.graph.relationships, fn rel ->
- %{rel | from_id: Map.get(b_id_remap, rel.from_id, rel.from_id),
- to_id: Map.get(b_id_remap, rel.to_id, rel.to_id)}
- end)
+ rels_from_b =
+ Enum.map(a2.graph.relationships, fn rel ->
+ %{
+ rel
+ | from_id: Map.get(b_id_remap, rel.from_id, rel.from_id),
+ to_id: Map.get(b_id_remap, rel.to_id, rel.to_id)
+ }
+ end)
relationships = deduplicate_rels(a1.graph.relationships, rels_from_b)
graph = %Artefact.Graph{
- nodes: nodes_from_a ++ b_nodes_reindexed,
+ nodes: nodes_from_a ++ b_nodes_reindexed,
relationships: relationships
}
- metadata = %{provenance: %{source: :harmonised, module: caller,
- left: %{title: a1.title, base_label: a1.base_label, uuid: a1.uuid, provenance: Map.get(a1.metadata, :provenance)},
- right: %{title: a2.title, base_label: a2.base_label, uuid: a2.uuid, provenance: Map.get(a2.metadata, :provenance)}}}
+ metadata = %{
+ provenance: %{
+ source: :harmonised,
+ module: caller,
+ left: %{
+ title: a1.title,
+ base_label: a1.base_label,
+ uuid: a1.uuid,
+ provenance: Map.get(a1.metadata, :provenance)
+ },
+ right: %{
+ title: a2.title,
+ base_label: a2.base_label,
+ uuid: a2.uuid,
+ provenance: Map.get(a2.metadata, :provenance)
+ }
+ }
+ }
+
build([{:title, title}, {:base_label, base_label}, {:graph, graph}, {:metadata, metadata}])
end
@doc false
def build(attrs) do
{node_specs, attrs} = Keyword.pop(attrs, :nodes, [])
- {rel_specs, attrs} = Keyword.pop(attrs, :relationships, [])
+ {rel_specs, attrs} = Keyword.pop(attrs, :relationships, [])
attrs =
if node_specs != [] or rel_specs != [] do
@@ -212,7 +502,10 @@ defmodule Artefact do
attrs
end
- struct!(__MODULE__, [{:id, Artefact.UUID.generate_v7()}, {:uuid, Artefact.UUID.generate_v7()} | attrs])
+ struct!(__MODULE__, [
+ {:id, Artefact.UUID.generate_v7()},
+ {:uuid, Artefact.UUID.generate_v7()} | attrs
+ ])
end
defp build_graph(node_specs, rel_specs) do
@@ -220,14 +513,16 @@ defmodule Artefact do
node_specs
|> Enum.with_index()
|> Enum.map_reduce(%{}, fn {{key, opts}, i}, acc ->
- id = "n#{i}"
+ id = "n#{i}"
+
node = %Artefact.Node{
- id: id,
- uuid: Keyword.get(opts, :uuid, Artefact.UUID.generate_v7()),
- labels: Keyword.get(opts, :labels, []),
+ id: id,
+ uuid: Keyword.get(opts, :uuid, Artefact.UUID.generate_v7()),
+ labels: Keyword.get(opts, :labels, []),
properties: Keyword.get(opts, :properties, %{}),
- position: Keyword.get(opts, :position)
+ position: Keyword.get(opts, :position)
}
+
{node, Map.put(acc, key, id)}
end)
@@ -236,10 +531,10 @@ defmodule Artefact do
|> Enum.with_index()
|> Enum.map(fn {spec, i} ->
%Artefact.Relationship{
- id: "r#{i}",
- from_id: Map.fetch!(key_map, Keyword.fetch!(spec, :from)),
- to_id: Map.fetch!(key_map, Keyword.fetch!(spec, :to)),
- type: Keyword.fetch!(spec, :type),
+ id: "r#{i}",
+ from_id: Map.fetch!(key_map, Keyword.fetch!(spec, :from)),
+ to_id: Map.fetch!(key_map, Keyword.fetch!(spec, :to)),
+ type: Keyword.fetch!(spec, :type),
properties: Keyword.get(spec, :properties, %{})
}
end)
@@ -253,9 +548,14 @@ defmodule Artefact do
merged_index =
Enum.reduce(rels_b, index, fn rel, acc ->
key = {rel.from_id, rel.type, rel.to_id}
+
case Map.fetch(acc, key) do
{:ok, existing} ->
- Map.put(acc, key, %{existing | properties: Map.merge(rel.properties, existing.properties)})
+ Map.put(acc, key, %{
+ existing
+ | properties: Map.merge(rel.properties, existing.properties)
+ })
+
:error ->
Map.put(acc, key, rel)
end
@@ -267,25 +567,26 @@ defmodule Artefact do
defp merge_graphs(g1, g2) do
offset = length(g1.nodes)
- id_map = g2.nodes
+ id_map =
+ g2.nodes
|> Enum.with_index(offset)
|> Map.new(fn {node, i} -> {node.id, "n#{i}"} end)
nodes =
g1.nodes ++
- Enum.map(g2.nodes, fn node -> %{node | id: id_map[node.id]} end)
+ Enum.map(g2.nodes, fn node -> %{node | id: id_map[node.id]} end)
rels =
g1.relationships ++
- Enum.map(g2.relationships, fn rel ->
- %{rel | from_id: id_map[rel.from_id], to_id: id_map[rel.to_id]}
- end)
+ Enum.map(g2.relationships, fn rel ->
+ %{rel | from_id: id_map[rel.from_id], to_id: id_map[rel.to_id]}
+ end)
%Artefact.Graph{nodes: nodes, relationships: rels}
end
defp portmanteau(nil, nil), do: nil
- defp portmanteau(a, nil), do: a
- defp portmanteau(nil, b), do: b
- defp portmanteau(a, b), do: a <> b
+ defp portmanteau(a, nil), do: a
+ defp portmanteau(nil, b), do: b
+ defp portmanteau(a, b), do: a <> b
end
diff --git a/artefact/lib/artefact/arrows.ex b/artefact/lib/artefact/arrows.ex
index 1853025..e31af60 100644
--- a/artefact/lib/artefact/arrows.ex
+++ b/artefact/lib/artefact/arrows.ex
@@ -35,6 +35,7 @@ defmodule Artefact.Arrows do
defp decode(raw, opts) do
base_label = Keyword.get(opts, :base_label, Map.get(raw, "base_label"))
+
nodes =
raw
|> Map.get("nodes", [])
@@ -43,9 +44,10 @@ defmodule Artefact.Arrows do
relationships = raw |> Map.get("relationships", []) |> Enum.map(&decode_relationship/1)
graph = %Artefact.Graph{nodes: nodes, relationships: relationships}
- metadata = Keyword.get(opts, :metadata,
- %{provenance: %{source: :arrows_json, diagram: Keyword.get(opts, :diagram)}}
- )
+ metadata =
+ Keyword.get(opts, :metadata, %{
+ provenance: %{source: :arrows_json, diagram: Keyword.get(opts, :diagram)}
+ })
%Artefact{
id: Keyword.get(opts, :id, Artefact.UUID.generate_v7()),
@@ -88,7 +90,13 @@ defmodule Artefact.Arrows do
# -- encode --
- defp encode(%Artefact{uuid: uuid, title: title, description: description, base_label: base_label, graph: graph}) do
+ defp encode(%Artefact{
+ uuid: uuid,
+ title: title,
+ description: description,
+ base_label: base_label,
+ graph: graph
+ }) do
%{
"uuid" => uuid,
"title" => title,
@@ -128,5 +136,4 @@ defmodule Artefact.Arrows do
"style" => %{}
}
end
-
end
diff --git a/artefact/lib/artefact/cypher.ex b/artefact/lib/artefact/cypher.ex
index 64a0459..084e077 100644
--- a/artefact/lib/artefact/cypher.ex
+++ b/artefact/lib/artefact/cypher.ex
@@ -37,7 +37,7 @@ defmodule Artefact.Cypher do
rel_stmts =
Enum.map(graph.relationships, fn rel ->
from = Enum.find(graph.nodes, &(&1.id == rel.from_id))
- to = Enum.find(graph.nodes, &(&1.id == rel.to_id))
+ to = Enum.find(graph.nodes, &(&1.id == rel.to_id))
inline_merge_rel_stmt(rel, from, to)
end)
@@ -78,7 +78,7 @@ defmodule Artefact.Cypher do
|> Enum.with_index()
|> Enum.map(fn {rel, idx} ->
from = Enum.find(graph.nodes, &(&1.id == rel.from_id))
- to = Enum.find(graph.nodes, &(&1.id == rel.to_id))
+ to = Enum.find(graph.nodes, &(&1.id == rel.to_id))
params_merge_rel_stmt(rel, from, to, idx)
end)
|> Enum.unzip()
@@ -91,11 +91,14 @@ defmodule Artefact.Cypher do
# -- inline (browser) merge helpers --
- defp inline_merge_node_stmt(%Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props}, base_label) do
+ defp inline_merge_node_stmt(
+ %Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props},
+ base_label
+ ) do
effective = effective_labels(labels, base_label)
label_str = Enum.map_join(effective, "", &":#{&1}")
set_labels = if label_str != "", do: "\nSET #{id}#{label_str}", else: ""
- set_props = if map_size(props) > 0, do: "\nSET #{id} += #{props_to_cypher(props)}", else: ""
+ set_props = if map_size(props) > 0, do: "\nSET #{id} += #{props_to_cypher(props)}", else: ""
"MERGE (#{id} {uuid: '#{uuid}'})#{set_labels}#{set_props}"
end
@@ -109,7 +112,10 @@ defmodule Artefact.Cypher do
# -- parameterised create helpers --
- defp params_node_pattern(%Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props}, base_label) do
+ defp params_node_pattern(
+ %Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props},
+ base_label
+ ) do
label_str = labels |> effective_labels(base_label) |> Enum.map_join("", &":#{&1}")
all_props = Map.put(props, "uuid", uuid)
@@ -126,20 +132,29 @@ defmodule Artefact.Cypher do
# -- parameterised merge helpers --
- defp params_merge_node_stmt(%Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props}, base_label) do
+ defp params_merge_node_stmt(
+ %Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props},
+ base_label
+ ) do
label_str = labels |> effective_labels(base_label) |> Enum.map_join("", &":#{&1}")
set_labels = if label_str != "", do: "\nSET #{id}#{label_str}", else: ""
- set_props = if map_size(props) > 0, do: "\nSET #{id} += $#{id}_props", else: ""
+ set_props = if map_size(props) > 0, do: "\nSET #{id} += $#{id}_props", else: ""
+
+ stmt = "MERGE (#{id} {uuid: $#{id}_uuid})#{set_labels}#{set_props}"
+
+ params =
+ Map.merge(
+ %{"#{id}_uuid" => uuid},
+ if(map_size(props) > 0, do: %{"#{id}_props" => props}, else: %{})
+ )
- stmt = "MERGE (#{id} {uuid: $#{id}_uuid})#{set_labels}#{set_props}"
- params = Map.merge(%{"#{id}_uuid" => uuid}, if(map_size(props) > 0, do: %{"#{id}_props" => props}, else: %{}))
{stmt, params}
end
defp params_merge_rel_stmt(%Artefact.Relationship{type: type, properties: props}, from, to, idx) do
if map_size(props) > 0 do
- rvar = "r#{idx}"
- stmt = "MERGE (#{from.id})-[#{rvar}:#{type}]->(#{to.id})\nSET #{rvar} += $#{rvar}_props"
+ rvar = "r#{idx}"
+ stmt = "MERGE (#{from.id})-[#{rvar}:#{type}]->(#{to.id})\nSET #{rvar} += $#{rvar}_props"
params = %{"#{rvar}_props" => props}
{stmt, params}
else
@@ -147,7 +162,10 @@ defmodule Artefact.Cypher do
end
end
- defp node_pattern(%Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props}, base_label) do
+ defp node_pattern(
+ %Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props},
+ base_label
+ ) do
label_str = labels |> effective_labels(base_label) |> Enum.map_join("", &":#{&1}")
prop_str = props_to_cypher(Map.put(props, "uuid", uuid))
"(#{id}#{label_str} #{prop_str})"
@@ -195,5 +213,4 @@ defmodule Artefact.Cypher do
defp cypher_value(true), do: "true"
defp cypher_value(false), do: "false"
defp cypher_value(nil), do: "null"
-
end
diff --git a/artefact/lib/artefact/mermaid.ex b/artefact/lib/artefact/mermaid.ex
index 2bda4d6..318cda6 100644
--- a/artefact/lib/artefact/mermaid.ex
+++ b/artefact/lib/artefact/mermaid.ex
@@ -117,7 +117,23 @@ defmodule Artefact.Mermaid do
defp needs_yaml_quoting?(s) do
String.contains?(s, [":", "\"", "#", "\n"]) or
- String.starts_with?(s, [" ", "\t", "&", "*", "!", "?", "{", "[", "|", ">", "%", "@", "`", "'", "-"])
+ String.starts_with?(s, [
+ " ",
+ "\t",
+ "&",
+ "*",
+ "!",
+ "?",
+ "{",
+ "[",
+ "|",
+ ">",
+ "%",
+ "@",
+ "`",
+ "'",
+ "-"
+ ])
end
# accTitle / accDescr inline form is single-line; collapse any newlines to spaces.
diff --git a/artefact/mix.exs b/artefact/mix.exs
index d96965b..a3ed5e3 100644
--- a/artefact/mix.exs
+++ b/artefact/mix.exs
@@ -5,7 +5,7 @@ defmodule Artefact.MixProject do
@moduledoc false
use Mix.Project
- @version "0.1.3"
+ @version "0.1.4"
@github_url "https://github.com/diffo-dev/artefactory"
def project do
@@ -14,6 +14,7 @@ defmodule Artefact.MixProject do
version: @version,
elixir: "~> 1.16",
start_permanent: Mix.env() == :prod,
+ elixirc_paths: elixirc_paths(Mix.env()),
deps: deps(),
package: package(),
name: "Artefact",
@@ -23,6 +24,9 @@ defmodule Artefact.MixProject do
]
end
+ defp elixirc_paths(:test), do: ["lib", "test/support"]
+ defp elixirc_paths(_), do: ["lib"]
+
def application do
[extra_applications: [:logger]]
end
@@ -47,7 +51,11 @@ defmodule Artefact.MixProject do
main: "Artefact",
source_url: @github_url,
source_ref: "v#{@version}",
- extras: ["README.md", "CHANGELOG.md"]
+ extras: [
+ "README.md",
+ "CHANGELOG.md",
+ {"LICENSES/MIT.txt", title: "License (MIT)"}
+ ]
]
end
end
diff --git a/artefact/test/artefact_test.exs b/artefact/test/artefact_test.exs
index 64f151f..c426387 100644
--- a/artefact/test/artefact_test.exs
+++ b/artefact/test/artefact_test.exs
@@ -7,8 +7,16 @@ defmodule ArtefactTest do
@fixtures Path.join(__DIR__, "data")
- defp shared_node, do: %Artefact.Node{id: "n0", uuid: "019d0000-0000-7000-8000-000000000000", labels: ["Shared"], properties: %{}}
- defp other_node(uuid), do: %Artefact.Node{id: "n1", uuid: uuid, labels: ["Other"], properties: %{}}
+ defp shared_node,
+ do: %Artefact.Node{
+ id: "n0",
+ uuid: "019d0000-0000-7000-8000-000000000000",
+ labels: ["Shared"],
+ properties: %{}
+ }
+
+ defp other_node(uuid),
+ do: %Artefact.Node{id: "n1", uuid: uuid, labels: ["Other"], properties: %{}}
defp artefact_with(nodes) do
Artefact.new(graph: %Artefact.Graph{nodes: nodes, relationships: []})
@@ -36,47 +44,93 @@ defmodule ArtefactTest do
a1 = Artefact.new()
a2 = Artefact.new()
composed = Artefact.compose(a1, a2)
- assert %{provenance: %{source: :composed, module: ArtefactTest,
- left: %{title: left_title, base_label: left_bl, uuid: left_uuid, provenance: left_prov},
- right: %{title: right_title, base_label: right_bl, uuid: right_uuid, provenance: right_prov}}} = composed.metadata
- assert left_title == a1.title
- assert left_bl == a1.base_label
- assert left_uuid == a1.uuid
+
+ assert %{
+ provenance: %{
+ source: :composed,
+ module: ArtefactTest,
+ left: %{
+ title: left_title,
+ base_label: left_bl,
+ uuid: left_uuid,
+ provenance: left_prov
+ },
+ right: %{
+ title: right_title,
+ base_label: right_bl,
+ uuid: right_uuid,
+ provenance: right_prov
+ }
+ }
+ } = composed.metadata
+
+ assert left_title == a1.title
+ assert left_bl == a1.base_label
+ assert left_uuid == a1.uuid
assert right_title == a2.title
- assert right_bl == a2.base_label
- assert right_uuid == a2.uuid
- assert left_prov == a1.metadata.provenance
- assert right_prov == a2.metadata.provenance
+ assert right_bl == a2.base_label
+ assert right_uuid == a2.uuid
+ assert left_prov == a1.metadata.provenance
+ assert right_prov == a2.metadata.provenance
end
test "harmonise records :harmonised provenance with left and right title, base_label, uuid and provenance" do
- a1 = Artefact.new(base_label: "LeftArtefact", graph: %Artefact.Graph{nodes: [shared_node()], relationships: []})
- a2 = Artefact.new(base_label: "RightArtefact", graph: %Artefact.Graph{nodes: [shared_node()], relationships: []})
+ a1 =
+ Artefact.new(
+ base_label: "LeftArtefact",
+ graph: %Artefact.Graph{nodes: [shared_node()], relationships: []}
+ )
+
+ a2 =
+ Artefact.new(
+ base_label: "RightArtefact",
+ graph: %Artefact.Graph{nodes: [shared_node()], relationships: []}
+ )
+
{:ok, bindings} = Artefact.Binding.find(a1, a2)
result = Artefact.harmonise(a1, a2, bindings)
- assert %{provenance: %{source: :harmonised, module: ArtefactTest,
- left: %{title: left_title, base_label: left_bl, uuid: left_uuid, provenance: left_prov},
- right: %{title: right_title, base_label: right_bl, uuid: right_uuid, provenance: right_prov}}} = result.metadata
- assert left_title == a1.title
- assert left_bl == a1.base_label
- assert left_uuid == a1.uuid
+
+ assert %{
+ provenance: %{
+ source: :harmonised,
+ module: ArtefactTest,
+ left: %{
+ title: left_title,
+ base_label: left_bl,
+ uuid: left_uuid,
+ provenance: left_prov
+ },
+ right: %{
+ title: right_title,
+ base_label: right_bl,
+ uuid: right_uuid,
+ provenance: right_prov
+ }
+ }
+ } = result.metadata
+
+ assert left_title == a1.title
+ assert left_bl == a1.base_label
+ assert left_uuid == a1.uuid
assert right_title == a2.title
- assert right_bl == a2.base_label
- assert right_uuid == a2.uuid
- assert left_prov == a1.metadata.provenance
- assert right_prov == a2.metadata.provenance
+ assert right_bl == a2.base_label
+ assert right_uuid == a2.uuid
+ assert left_prov == a1.metadata.provenance
+ assert right_prov == a2.metadata.provenance
end
end
describe "Artefact.new/1 — inline nodes and relationships" do
test "builds nodes with sequential ids" do
- a = Artefact.new(
- nodes: [
- matt: [labels: ["Agent", "Me"], properties: %{"name" => "Matt"}],
- claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"}]
- ],
- relationships: []
- )
+ a =
+ Artefact.new(
+ nodes: [
+ matt: [labels: ["Agent", "Me"], properties: %{"name" => "Matt"}],
+ claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"}]
+ ],
+ relationships: []
+ )
+
by_id = Map.new(a.graph.nodes, &{&1.id, &1})
assert map_size(by_id) == 2
assert Map.has_key?(by_id, "n0")
@@ -84,13 +138,15 @@ defmodule ArtefactTest do
end
test "nodes have correct labels and properties" do
- a = Artefact.new(
- nodes: [
- matt: [labels: ["Agent", "Me"], properties: %{"name" => "Matt"}],
- claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"}]
- ],
- relationships: []
- )
+ a =
+ Artefact.new(
+ nodes: [
+ matt: [labels: ["Agent", "Me"], properties: %{"name" => "Matt"}],
+ claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"}]
+ ],
+ relationships: []
+ )
+
by_id = Map.new(a.graph.nodes, &{&1.id, &1})
assert by_id["n0"].labels == ["Agent", "Me"]
assert by_id["n0"].properties == %{"name" => "Matt"}
@@ -113,35 +169,41 @@ defmodule ArtefactTest do
end
test "builds relationship resolving atom keys to ids" do
- a = Artefact.new(
- nodes: [
- matt: [labels: ["Agent"]],
- claude: [labels: ["Agent"]]
- ],
- relationships: [
- [from: :matt, type: "US_TWO", to: :claude]
- ]
- )
+ a =
+ Artefact.new(
+ nodes: [
+ matt: [labels: ["Agent"]],
+ claude: [labels: ["Agent"]]
+ ],
+ relationships: [
+ [from: :matt, type: "US_TWO", to: :claude]
+ ]
+ )
+
[rel] = a.graph.relationships
assert rel.from_id == "n0"
- assert rel.to_id == "n1"
- assert rel.type == "US_TWO"
+ assert rel.to_id == "n1"
+ assert rel.type == "US_TWO"
end
test "relationship properties default to empty map" do
- a = Artefact.new(
- nodes: [a: [labels: []], b: [labels: []]],
- relationships: [[from: :a, type: "KNOWS", to: :b]]
- )
+ a =
+ Artefact.new(
+ nodes: [a: [labels: []], b: [labels: []]],
+ relationships: [[from: :a, type: "KNOWS", to: :b]]
+ )
+
[rel] = a.graph.relationships
assert rel.properties == %{}
end
test "relationship properties are set when provided" do
- a = Artefact.new(
- nodes: [a: [labels: []], b: [labels: []]],
- relationships: [[from: :a, type: "KNOWS", to: :b, properties: %{"since" => "2024"}]]
- )
+ a =
+ Artefact.new(
+ nodes: [a: [labels: []], b: [labels: []]],
+ relationships: [[from: :a, type: "KNOWS", to: :b, properties: %{"since" => "2024"}]]
+ )
+
[rel] = a.graph.relationships
assert rel.properties == %{"since" => "2024"}
end
@@ -160,13 +222,15 @@ defmodule ArtefactTest do
describe "Artefact.new/1 — inline nodes and relationships — multiple relationships" do
setup do
- a = Artefact.new(
- nodes: [x: [labels: ["X"]], y: [labels: ["Y"]], z: [labels: ["Z"]]],
- relationships: [
- [from: :x, type: "NEXT", to: :y],
- [from: :y, type: "NEXT", to: :z]
- ]
- )
+ a =
+ Artefact.new(
+ nodes: [x: [labels: ["X"]], y: [labels: ["Y"]], z: [labels: ["Z"]]],
+ relationships: [
+ [from: :x, type: "NEXT", to: :y],
+ [from: :y, type: "NEXT", to: :z]
+ ]
+ )
+
%{artefact: a}
end
@@ -194,30 +258,37 @@ defmodule ArtefactTest do
json = File.read!(Path.join([@fixtures, "us_two", "arrows.json"]))
from_json = Artefact.Arrows.from_json!(json)
- from_struct = Artefact.new(
- title: "UsTwo",
- base_label: "UsTwo",
- nodes: [
- matt: [labels: ["Agent", "Me"], properties: %{"name" => "Matt"},
- uuid: "019da897-f2de-77ca-b5a4-40f0c3730943"],
- claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"},
- uuid: "019da897-f2de-768c-94e2-3005f2431f37"]
- ],
- relationships: [
- [from: :matt, type: "US_TWO", to: :claude]
- ]
- )
+ from_struct =
+ Artefact.new(
+ title: "UsTwo",
+ base_label: "UsTwo",
+ nodes: [
+ matt: [
+ labels: ["Agent", "Me"],
+ properties: %{"name" => "Matt"},
+ uuid: "019da897-f2de-77ca-b5a4-40f0c3730943"
+ ],
+ claude: [
+ labels: ["Agent", "You"],
+ properties: %{"name" => "Claude"},
+ uuid: "019da897-f2de-768c-94e2-3005f2431f37"
+ ]
+ ],
+ relationships: [
+ [from: :matt, type: "US_TWO", to: :claude]
+ ]
+ )
%{from_json: from_json, from_struct: from_struct}
end
test "same title and base_label", %{from_json: j, from_struct: s} do
- assert s.title == j.title
+ assert s.title == j.title
assert s.base_label == j.base_label
end
test "same number of nodes and relationships", %{from_json: j, from_struct: s} do
- assert length(s.graph.nodes) == length(j.graph.nodes)
+ assert length(s.graph.nodes) == length(j.graph.nodes)
assert length(s.graph.relationships) == length(j.graph.relationships)
end
@@ -242,7 +313,7 @@ defmodule ArtefactTest do
assert sr.type == jr.type
from_uuid = fn a, rel_id -> Enum.find(a.graph.nodes, &(&1.id == rel_id)).uuid end
assert from_uuid.(s, sr.from_id) == from_uuid.(j, jr.from_id)
- assert from_uuid.(s, sr.to_id) == from_uuid.(j, jr.to_id)
+ assert from_uuid.(s, sr.to_id) == from_uuid.(j, jr.to_id)
end
test "inline build has :struct provenance", %{from_struct: s} do
@@ -328,7 +399,9 @@ defmodule ArtefactTest do
test "to_json/from_json! preserves nodes and relationships" do
json = File.read!(Path.join([@fixtures, "us_two", "arrows.json"]))
original = Artefact.Arrows.from_json!(json, id: "rt-test")
- round_tripped = original |> Artefact.Arrows.to_json() |> Artefact.Arrows.from_json!(id: "rt-test")
+
+ round_tripped =
+ original |> Artefact.Arrows.to_json() |> Artefact.Arrows.from_json!(id: "rt-test")
assert length(round_tripped.graph.nodes) == length(original.graph.nodes)
assert length(round_tripped.graph.relationships) == length(original.graph.relationships)
@@ -351,7 +424,9 @@ defmodule ArtefactTest do
a1 = artefact_with([shared_node(), other_node("019d0000-0000-7000-8000-000000000001")])
a2 = artefact_with([shared_node(), other_node("019d0000-0000-7000-8000-000000000002")])
- assert {:ok, [%Artefact.Binding{uuid_a: uuid, uuid_b: uuid}]} = Artefact.Binding.find(a1, a2)
+ assert {:ok, [%Artefact.Binding{uuid_a: uuid, uuid_b: uuid}]} =
+ Artefact.Binding.find(a1, a2)
+
assert uuid == shared_node().uuid
end
@@ -392,8 +467,12 @@ defmodule ArtefactTest do
defp artefact_nodes(nodes) do
%Artefact{
- id: Artefact.UUID.generate_v7(), uuid: Artefact.UUID.generate_v7(),
- title: nil, base_label: nil, style: nil, metadata: %{},
+ id: Artefact.UUID.generate_v7(),
+ uuid: Artefact.UUID.generate_v7(),
+ title: nil,
+ base_label: nil,
+ style: nil,
+ metadata: %{},
graph: %Artefact.Graph{nodes: nodes, relationships: []}
}
end
@@ -443,8 +522,20 @@ defmodule ArtefactTest do
end
test "shared label appears once in union" do
- n_a = %Artefact.Node{id: "n0", uuid: @uuid_shared, labels: ["Shared", "OnlyA"], properties: %{}}
- n_b = %Artefact.Node{id: "n0", uuid: @uuid_shared, labels: ["Shared", "OnlyB"], properties: %{}}
+ n_a = %Artefact.Node{
+ id: "n0",
+ uuid: @uuid_shared,
+ labels: ["Shared", "OnlyA"],
+ properties: %{}
+ }
+
+ n_b = %Artefact.Node{
+ id: "n0",
+ uuid: @uuid_shared,
+ labels: ["Shared", "OnlyB"],
+ properties: %{}
+ }
+
a1 = artefact_nodes([n_a])
a2 = artefact_nodes([n_b])
{:ok, bindings} = Artefact.Binding.find(a1, a2)
@@ -458,6 +549,7 @@ defmodule ArtefactTest do
test "raises when harmonising an artefact with itself" do
a = artefact_with([shared_node()])
{:ok, bindings} = Artefact.Binding.find(a, a)
+
assert_raise ArgumentError, ~r/cannot harmonise an artefact with itself/, fn ->
Artefact.harmonise(a, a, bindings)
end
@@ -466,6 +558,7 @@ defmodule ArtefactTest do
test "raises when both artefacts have the same base_label" do
a1 = Artefact.new(base_label: "Same")
a2 = Artefact.new(base_label: "Same")
+
assert_raise ArgumentError, ~r/cannot harmonise artefacts with the same base_label/, fn ->
Artefact.harmonise(a1, a2, [])
end
@@ -481,48 +574,125 @@ defmodule ArtefactTest do
%Artefact.Node{id: id_x, uuid: uuid_x, labels: [], properties: %{}},
%Artefact.Node{id: id_y, uuid: uuid_y, labels: [], properties: %{}}
]
+
%Artefact{
- id: Artefact.UUID.generate_v7(), uuid: Artefact.UUID.generate_v7(),
- title: nil, base_label: nil, style: nil, metadata: %{},
+ id: Artefact.UUID.generate_v7(),
+ uuid: Artefact.UUID.generate_v7(),
+ title: nil,
+ base_label: nil,
+ style: nil,
+ metadata: %{},
graph: %Artefact.Graph{nodes: nodes, relationships: rels}
}
end
test "identical relationship appears once after harmonise" do
- a1 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
- [%Artefact.Relationship{id: "r0", from_id: "n0", to_id: "n1", type: "KNOWS", properties: %{}}])
- a2 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
- [%Artefact.Relationship{id: "r0", from_id: "n0", to_id: "n1", type: "KNOWS", properties: %{}}])
+ a1 =
+ two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+ %Artefact.Relationship{
+ id: "r0",
+ from_id: "n0",
+ to_id: "n1",
+ type: "KNOWS",
+ properties: %{}
+ }
+ ])
+
+ a2 =
+ two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+ %Artefact.Relationship{
+ id: "r0",
+ from_id: "n0",
+ to_id: "n1",
+ type: "KNOWS",
+ properties: %{}
+ }
+ ])
+
{:ok, bindings} = Artefact.Binding.find(a1, a2)
result = Artefact.harmonise(a1, a2, bindings)
assert length(result.graph.relationships) == 1
end
test "different type relationships both survive" do
- a1 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
- [%Artefact.Relationship{id: "r0", from_id: "n0", to_id: "n1", type: "KNOWS", properties: %{}}])
- a2 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
- [%Artefact.Relationship{id: "r1", from_id: "n0", to_id: "n1", type: "TRUSTS", properties: %{}}])
+ a1 =
+ two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+ %Artefact.Relationship{
+ id: "r0",
+ from_id: "n0",
+ to_id: "n1",
+ type: "KNOWS",
+ properties: %{}
+ }
+ ])
+
+ a2 =
+ two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+ %Artefact.Relationship{
+ id: "r1",
+ from_id: "n0",
+ to_id: "n1",
+ type: "TRUSTS",
+ properties: %{}
+ }
+ ])
+
{:ok, bindings} = Artefact.Binding.find(a1, a2)
result = Artefact.harmonise(a1, a2, bindings)
assert length(result.graph.relationships) == 2
end
test "opposite direction relationships both survive" do
- a1 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
- [%Artefact.Relationship{id: "r0", from_id: "n0", to_id: "n1", type: "KNOWS", properties: %{}}])
- a2 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
- [%Artefact.Relationship{id: "r1", from_id: "n1", to_id: "n0", type: "KNOWS", properties: %{}}])
+ a1 =
+ two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+ %Artefact.Relationship{
+ id: "r0",
+ from_id: "n0",
+ to_id: "n1",
+ type: "KNOWS",
+ properties: %{}
+ }
+ ])
+
+ a2 =
+ two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+ %Artefact.Relationship{
+ id: "r1",
+ from_id: "n1",
+ to_id: "n0",
+ type: "KNOWS",
+ properties: %{}
+ }
+ ])
+
{:ok, bindings} = Artefact.Binding.find(a1, a2)
result = Artefact.harmonise(a1, a2, bindings)
assert length(result.graph.relationships) == 2
end
test "duplicate relationship properties merged left-wins" do
- a1 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
- [%Artefact.Relationship{id: "r0", from_id: "n0", to_id: "n1", type: "KNOWS", properties: %{"since" => "2020", "trust" => "high"}}])
- a2 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
- [%Artefact.Relationship{id: "r1", from_id: "n0", to_id: "n1", type: "KNOWS", properties: %{"since" => "2019", "source" => "intro"}}])
+ a1 =
+ two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+ %Artefact.Relationship{
+ id: "r0",
+ from_id: "n0",
+ to_id: "n1",
+ type: "KNOWS",
+ properties: %{"since" => "2020", "trust" => "high"}
+ }
+ ])
+
+ a2 =
+ two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+ %Artefact.Relationship{
+ id: "r1",
+ from_id: "n0",
+ to_id: "n1",
+ type: "KNOWS",
+ properties: %{"since" => "2019", "source" => "intro"}
+ }
+ ])
+
{:ok, bindings} = Artefact.Binding.find(a1, a2)
result = Artefact.harmonise(a1, a2, bindings)
[rel] = result.graph.relationships
@@ -553,7 +723,9 @@ defmodule ArtefactTest do
describe "Artefact.Cypher.create/1 — us_two" do
test "matches fixture" do
json = File.read!(Path.join([@fixtures, "us_two", "arrows.json"]))
- expected = File.read!(Path.join([@fixtures, "us_two", "create_cypher.txt"])) |> String.trim()
+
+ expected =
+ File.read!(Path.join([@fixtures, "us_two", "create_cypher.txt"])) |> String.trim()
artefact = Artefact.Arrows.from_json!(json)
assert Artefact.Cypher.create(artefact) == expected
@@ -613,7 +785,11 @@ defmodule ArtefactTest do
end)
end
- test "node properties are in params not inline", %{artefact: a, cypher: cypher, params: params} do
+ test "node properties are in params not inline", %{
+ artefact: a,
+ cypher: cypher,
+ params: params
+ } do
Enum.each(a.graph.nodes, fn node ->
assert String.contains?(cypher, "$#{node.id}_props")
assert params["#{node.id}_props"] == node.properties
@@ -639,7 +815,11 @@ defmodule ArtefactTest do
refute String.contains?(cypher, "MERGE")
end
- test "node properties are in params not inline", %{artefact: a, cypher: cypher, params: params} do
+ test "node properties are in params not inline", %{
+ artefact: a,
+ cypher: cypher,
+ params: params
+ } do
Enum.each(a.graph.nodes, fn node ->
Enum.each(node.properties, fn {k, v} ->
assert String.contains?(cypher, "$#{node.id}_#{k}")
@@ -673,7 +853,9 @@ defmodule ArtefactTest do
end
test "Cypher export matches fixture", %{artefact: a} do
- expected = File.read!(Path.join([@fixtures, "artefact", "create_cypher.txt"])) |> String.trim()
+ expected =
+ File.read!(Path.join([@fixtures, "artefact", "create_cypher.txt"])) |> String.trim()
+
assert Artefact.Cypher.create(a) == expected
end
end
@@ -707,12 +889,18 @@ defmodule ArtefactTest do
end
test "create Cypher matches fixture", %{artefact: a} do
- expected = File.read!(Path.join([@fixtures, "artefact_harmonise", "create_cypher.txt"])) |> String.trim()
+ expected =
+ File.read!(Path.join([@fixtures, "artefact_harmonise", "create_cypher.txt"]))
+ |> String.trim()
+
assert Artefact.Cypher.create(a) == expected
end
test "merge Cypher matches fixture", %{artefact: a} do
- expected = File.read!(Path.join([@fixtures, "artefact_harmonise", "merge_cypher.txt"])) |> String.trim()
+ expected =
+ File.read!(Path.join([@fixtures, "artefact_harmonise", "merge_cypher.txt"]))
+ |> String.trim()
+
assert Artefact.Cypher.merge(a) == expected
end
end
@@ -743,12 +931,18 @@ defmodule ArtefactTest do
end
test "create Cypher matches fixture", %{artefact: a} do
- expected = File.read!(Path.join([@fixtures, "artefact_combine", "create_cypher.txt"])) |> String.trim()
+ expected =
+ File.read!(Path.join([@fixtures, "artefact_combine", "create_cypher.txt"]))
+ |> String.trim()
+
assert Artefact.Cypher.create(a) == expected
end
test "merge Cypher matches fixture", %{artefact: a} do
- expected = File.read!(Path.join([@fixtures, "artefact_combine", "merge_cypher.txt"])) |> String.trim()
+ expected =
+ File.read!(Path.join([@fixtures, "artefact_combine", "merge_cypher.txt"]))
+ |> String.trim()
+
assert Artefact.Cypher.merge(a) == expected
end
end
@@ -1090,4 +1284,310 @@ defmodule ArtefactTest do
assert_raise MatchError, fn -> Artefact.combine(heart, other) end
end
end
+
+ describe "Artefact.graft/3 — happy path with OurShells fixture" do
+ alias Artefact.Test.Fixtures.OurShells
+
+ setup do
+ left = OurShells.our_shells()
+
+ result =
+ Artefact.graft(left, OurShells.manifesto_args(),
+ title: "Our Shells and Manifesto",
+ description: "Our Shells and Manifesto shape our Association Knowing."
+ )
+
+ %{left: left, result: result}
+ end
+
+ test "result has opts title and description", %{result: r} do
+ assert r.title == "Our Shells and Manifesto"
+ assert r.description == "Our Shells and Manifesto shape our Association Knowing."
+ end
+
+ test "result keeps left base_label", %{left: left, result: r} do
+ assert r.base_label == left.base_label
+ end
+
+ test "result is a fresh artefact (new uuid)", %{left: left, result: r} do
+ assert r.uuid != left.uuid
+ end
+
+ test "new args nodes are appended to left graph", %{left: left, result: r} do
+ assert length(r.graph.nodes) == length(left.graph.nodes) + 3
+
+ uuids = Enum.map(r.graph.nodes, & &1.uuid)
+ assert OurShells.ethics_uuid() in uuids
+ assert OurShells.stewardship_uuid() in uuids
+ assert OurShells.intent_uuid() in uuids
+ end
+
+ test "new node ids continue left's offset", %{left: left, result: r} do
+ offset = length(left.graph.nodes)
+
+ new_uuids =
+ MapSet.new([
+ OurShells.ethics_uuid(),
+ OurShells.stewardship_uuid(),
+ OurShells.intent_uuid()
+ ])
+
+ new_nodes = Enum.filter(r.graph.nodes, &MapSet.member?(new_uuids, &1.uuid))
+ ids = new_nodes |> Enum.map(& &1.id) |> Enum.sort()
+ expected = for i <- offset..(offset + 2), do: "n#{i}"
+ assert ids == Enum.sort(expected)
+ end
+
+ test "bind-only nodes preserve their existing id", %{left: left, result: r} do
+ left_by_uuid = Map.new(left.graph.nodes, &{&1.uuid, &1})
+ result_by_uuid = Map.new(r.graph.nodes, &{&1.uuid, &1})
+
+ for uuid <- [
+ OurShells.me_uuid(),
+ OurShells.council_uuid(),
+ OurShells.core_uuid(),
+ OurShells.association_uuid()
+ ] do
+ assert result_by_uuid[uuid].id == left_by_uuid[uuid].id
+ end
+ end
+
+ test "new relationships from args are added", %{left: left, result: r} do
+ assert length(r.graph.relationships) == length(left.graph.relationships) + 4
+ end
+
+ test "the four new KNOWING relationships are present", %{result: r} do
+ result_by_uuid = Map.new(r.graph.nodes, &{&1.uuid, &1})
+
+ pair = fn from_uuid, to_uuid ->
+ from_id = result_by_uuid[from_uuid].id
+ to_id = result_by_uuid[to_uuid].id
+
+ Enum.any?(r.graph.relationships, fn rel ->
+ rel.from_id == from_id and rel.to_id == to_id and rel.type == "KNOWING"
+ end)
+ end
+
+ assert pair.(OurShells.me_uuid(), OurShells.stewardship_uuid())
+ assert pair.(OurShells.council_uuid(), OurShells.ethics_uuid())
+ assert pair.(OurShells.core_uuid(), OurShells.intent_uuid())
+ assert pair.(OurShells.association_uuid(), OurShells.stewardship_uuid())
+ end
+
+ test "new relationships connect the right node ids", %{result: r} do
+ result_by_uuid = Map.new(r.graph.nodes, &{&1.uuid, &1})
+ me_id = result_by_uuid[OurShells.me_uuid()].id
+ stewardship_id = result_by_uuid[OurShells.stewardship_uuid()].id
+
+ assert Enum.any?(r.graph.relationships, fn rel ->
+ rel.from_id == me_id and rel.to_id == stewardship_id and rel.type == "KNOWING"
+ end)
+ end
+
+ test "records :grafted provenance with right title and description", %{left: left, result: r} do
+ assert %{
+ provenance: %{
+ source: :grafted,
+ module: ArtefactTest,
+ left: %{
+ title: left_title,
+ base_label: left_bl,
+ uuid: left_uuid,
+ provenance: left_prov
+ },
+ right: %{title: right_title, description: right_desc}
+ }
+ } = r.metadata
+
+ assert left_title == left.title
+ assert left_bl == left.base_label
+ assert left_uuid == left.uuid
+ assert left_prov == left.metadata.provenance
+
+ assert right_title == "Our Shells and Manifesto"
+ assert right_desc == "Our Shells and Manifesto shape our Association Knowing."
+ end
+ end
+
+ describe "Artefact.graft/3 — opts behaviour" do
+ alias Artefact.Test.Fixtures.OurShells
+
+ test "title and description fall back to left when opts omits them" do
+ left = OurShells.our_shells()
+ result = Artefact.graft(left, OurShells.manifesto_args())
+
+ assert result.title == left.title
+ assert result.description == left.description
+ end
+
+ test "right provenance carries nil when opts omits title and description" do
+ left = OurShells.our_shells()
+ result = Artefact.graft(left, OurShells.manifesto_args())
+
+ assert %{provenance: %{right: %{title: nil, description: nil}}} = result.metadata
+ end
+
+ test "base_label in opts is ignored — left's base_label always wins" do
+ left = OurShells.our_shells()
+ result = Artefact.graft(left, OurShells.manifesto_args(), base_label: "ShouldBeIgnored")
+
+ assert result.base_label == left.base_label
+ end
+ end
+
+ describe "Artefact.graft/3 — bind-only merge semantics" do
+ @left_uuid "019d0000-0000-7000-8000-0000000000aa"
+
+ defp single_node_artefact(labels, properties) do
+ Artefact.new(
+ title: "Left",
+ nodes: [n: [labels: labels, properties: properties, uuid: @left_uuid]],
+ relationships: []
+ )
+ end
+
+ test "bind-only with new labels — labels are unioned" do
+ left = single_node_artefact(["LeftLabel"], %{})
+
+ result =
+ Artefact.graft(left,
+ nodes: [n: [labels: ["RightLabel"], uuid: @left_uuid]],
+ relationships: []
+ )
+
+ [node] = result.graph.nodes
+ assert Enum.sort(node.labels) == ["LeftLabel", "RightLabel"]
+ end
+
+ test "bind-only with shared label — appears once" do
+ left = single_node_artefact(["Shared", "OnlyLeft"], %{})
+
+ result =
+ Artefact.graft(left,
+ nodes: [n: [labels: ["Shared", "OnlyRight"], uuid: @left_uuid]],
+ relationships: []
+ )
+
+ [node] = result.graph.nodes
+ assert Enum.sort(node.labels) == ["OnlyLeft", "OnlyRight", "Shared"]
+ end
+
+ test "bind-only with new property keys — both survive" do
+ left = single_node_artefact([], %{"left_key" => "L"})
+
+ result =
+ Artefact.graft(left,
+ nodes: [n: [properties: %{"right_key" => "R"}, uuid: @left_uuid]],
+ relationships: []
+ )
+
+ [node] = result.graph.nodes
+ assert node.properties == %{"left_key" => "L", "right_key" => "R"}
+ end
+
+ test "bind-only with conflicting property — left wins" do
+ left = single_node_artefact([], %{"shared_key" => "from_left"})
+
+ result =
+ Artefact.graft(left,
+ nodes: [n: [properties: %{"shared_key" => "from_right"}, uuid: @left_uuid]],
+ relationships: []
+ )
+
+ [node] = result.graph.nodes
+ assert node.properties["shared_key"] == "from_left"
+ end
+
+ test "bind-only does not append a new node" do
+ left = single_node_artefact(["X"], %{})
+
+ result =
+ Artefact.graft(left,
+ nodes: [n: [uuid: @left_uuid]],
+ relationships: []
+ )
+
+ assert length(result.graph.nodes) == length(left.graph.nodes)
+ end
+ end
+
+ describe "Artefact.graft/3 — relationship dedupe" do
+ @uuid_a "019d0000-0000-7000-8000-0000000000b1"
+ @uuid_b "019d0000-0000-7000-8000-0000000000b2"
+
+ test "args relationship matching an existing left relationship is deduped (left properties win)" do
+ left =
+ Artefact.new(
+ title: "Pair",
+ nodes: [
+ a: [labels: [], properties: %{}, uuid: @uuid_a],
+ b: [labels: [], properties: %{}, uuid: @uuid_b]
+ ],
+ relationships: [[from: :a, type: "KNOWS", to: :b, properties: %{"since" => "2020"}]]
+ )
+
+ result =
+ Artefact.graft(left,
+ nodes: [
+ a: [uuid: @uuid_a],
+ b: [uuid: @uuid_b]
+ ],
+ relationships: [
+ [
+ from: :a,
+ type: "KNOWS",
+ to: :b,
+ properties: %{"since" => "2099", "source" => "graft"}
+ ]
+ ]
+ )
+
+ assert length(result.graph.relationships) == 1
+ [rel] = result.graph.relationships
+ assert rel.properties["since"] == "2020"
+ assert rel.properties["source"] == "graft"
+ end
+ end
+
+ describe "Artefact.graft/3 — guards" do
+ alias Artefact.Test.Fixtures.OurShells
+
+ test "raises when an args node is missing :uuid" do
+ left = OurShells.our_shells()
+
+ assert_raise ArgumentError, ~r/graft: node :without_uuid is missing required :uuid/, fn ->
+ Artefact.graft(left,
+ nodes: [without_uuid: [labels: ["Knowing"]]],
+ relationships: []
+ )
+ end
+ end
+
+ test "raises when args has duplicate node keys" do
+ left = OurShells.our_shells()
+
+ assert_raise ArgumentError, ~r/graft: duplicate node keys/, fn ->
+ Artefact.graft(left,
+ nodes: [
+ {:dup, [uuid: "019d0000-0000-7000-8000-000000000c01"]},
+ {:dup, [uuid: "019d0000-0000-7000-8000-000000000c02"]}
+ ],
+ relationships: []
+ )
+ end
+ end
+
+ test "raises when a relationship references a key not in args.nodes" do
+ left = OurShells.our_shells()
+
+ assert_raise ArgumentError,
+ ~r/graft: relationship references unknown node key :ghost/,
+ fn ->
+ Artefact.graft(left,
+ nodes: [{:me, [uuid: OurShells.me_uuid()]}],
+ relationships: [[from: :me, type: "KNOWING", to: :ghost]]
+ )
+ end
+ end
+ end
end
diff --git a/artefact/test/support/our_shells_fixture.ex b/artefact/test/support/our_shells_fixture.ex
new file mode 100644
index 0000000..1c171d1
--- /dev/null
+++ b/artefact/test/support/our_shells_fixture.ex
@@ -0,0 +1,118 @@
+# SPDX-FileCopyrightText: 2026 artefactory contributors
+# SPDX-License-Identifier: MIT
+
+defmodule Artefact.Test.Fixtures.OurShells do
+ @moduledoc """
+ Test fixture in `Artefact.new` form, adapted from
+ `diffo-dev/.github/livebook/shells.livemd`.
+
+ Provides a small "Our Shells" artefact (`our_shells/0`) and a matching
+ graft args set (`manifesto_args/0`) that mixes bind-only references to
+ existing nodes with brand-new nodes and relationships.
+
+ Used to exercise `Artefact.graft/3`.
+ """
+
+ require Artefact
+
+ # Stable uuids — same as the shells livebook so the fixture reads the same
+ @me_uuid "019ddb71-c70b-7b3e-83b1-58f4d0be2852"
+ @valuing_uuid "019ddb7f-a43d-7525-bb4f-bfd32d110719"
+ @beings_uuid "019de8bb-86b0-7acf-b1b8-40e96a3775a6"
+ @shells_uuid "019df584-d80b-798a-8b83-077273c43cea"
+ @council_uuid "019df523-66a7-7dca-93c6-ec9579e9408f"
+ @core_uuid "019df524-0bbf-7272-879a-20cba847223b"
+ @association_uuid "019df524-638e-7fba-832a-b0f216843232"
+
+ # Brand-new uuids the graft introduces
+ @ethics_uuid "019df311-16f0-7eea-a66f-a5c502551c6d"
+ @stewardship_uuid "019df318-698c-77d6-bc7b-ea041a019a7f"
+ @intent_uuid "019df317-1c9d-7d84-afe8-0f356db70103"
+
+ def me_uuid, do: @me_uuid
+ def valuing_uuid, do: @valuing_uuid
+ def beings_uuid, do: @beings_uuid
+ def shells_uuid, do: @shells_uuid
+ def council_uuid, do: @council_uuid
+ def core_uuid, do: @core_uuid
+ def association_uuid, do: @association_uuid
+
+ def ethics_uuid, do: @ethics_uuid
+ def stewardship_uuid, do: @stewardship_uuid
+ def intent_uuid, do: @intent_uuid
+
+ @doc """
+ The "Our Shells" artefact — the canonical *left* in graft tests.
+ """
+ def our_shells do
+ me = {:me, [labels: ["Agent"], properties: %{"name" => "me"}, uuid: @me_uuid]}
+
+ valuing =
+ {:valuing, [labels: ["Knowing"], properties: %{"name" => "valuing"}, uuid: @valuing_uuid]}
+
+ beings =
+ {:beings, [labels: ["Valuing"], properties: %{"name" => "beings"}, uuid: @beings_uuid]}
+
+ shells =
+ {:shells, [labels: ["Knowing"], properties: %{"name" => "shells"}, uuid: @shells_uuid]}
+
+ council =
+ {:council,
+ [labels: ["Shell", "Beings"], properties: %{"name" => "council"}, uuid: @council_uuid]}
+
+ core =
+ {:core, [labels: ["Shell", "Beings"], properties: %{"name" => "core"}, uuid: @core_uuid]}
+
+ association =
+ {:association,
+ [
+ labels: ["Shell", "Beings"],
+ properties: %{"name" => "association"},
+ uuid: @association_uuid
+ ]}
+
+ Artefact.new(
+ title: "Our Shells",
+ description: "Our Shells help us value Beings.",
+ nodes: [me, valuing, beings, shells, council, core, association],
+ relationships: [
+ [from: :me, type: "VALUING", to: :valuing],
+ [from: :valuing, type: "CONSIDERING", to: :beings],
+ [from: :me, type: "KNOWING", to: :shells],
+ [from: :beings, type: "LIKELY_IN", to: :shells],
+ [from: :council, type: "INNERMOST", to: :shells],
+ [from: :core, type: "INSIDE", to: :council],
+ [from: :core, type: "INSIDE", to: :association]
+ ]
+ )
+ end
+
+ @doc """
+ Graft args adapted from the shells.livemd "Our Shells and Manifesto"
+ step. Mixes bind-only references (`:me`, `:council`, `:core`,
+ `:association`) with new nodes (`:ethics`, `:stewardship`, `:intent`)
+ and a handful of new relationships that span both.
+ """
+ def manifesto_args do
+ [
+ nodes: [
+ # bind-only — uuid lives in our_shells
+ {:me, [uuid: @me_uuid]},
+ {:council, [uuid: @council_uuid]},
+ {:core, [uuid: @core_uuid]},
+ {:association, [uuid: @association_uuid]},
+ # new
+ {:ethics, [labels: ["Knowing"], properties: %{"name" => "ethics"}, uuid: @ethics_uuid]},
+ {:stewardship,
+ [labels: ["Knowing"], properties: %{"name" => "stewardship"}, uuid: @stewardship_uuid]},
+ {:intent, [labels: ["Knowing"], properties: %{"name" => "intent"}, uuid: @intent_uuid]}
+ ],
+ relationships: [
+ [from: :me, type: "KNOWING", to: :stewardship],
+ [from: :council, type: "KNOWING", to: :ethics],
+ [from: :core, type: "KNOWING", to: :intent],
+ [from: :association, type: "KNOWING", to: :stewardship]
+ ]
+ ]
+ end
+end