From 6d8def22d277303d5d204bb5a854ad9cc262d814 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Tue, 5 May 2026 14:19:32 +0930 Subject: [PATCH 1/3] artefact graft --- .drafts/convenience-graft.md | 107 +++ artefact/README.md | 33 +- artefact/lib/artefact.ex | 405 +++++++++-- artefact/lib/artefact/arrows.ex | 17 +- artefact/lib/artefact/cypher.ex | 43 +- artefact/lib/artefact/mermaid.ex | 18 +- artefact/mix.exs | 10 +- artefact/test/artefact_test.exs | 724 +++++++++++++++++--- artefact/test/support/our_shells_fixture.ex | 118 ++++ 9 files changed, 1287 insertions(+), 188 deletions(-) create mode 100644 .drafts/convenience-graft.md create mode 100644 artefact/test/support/our_shells_fixture.ex diff --git a/.drafts/convenience-graft.md b/.drafts/convenience-graft.md new file mode 100644 index 0000000..55c692e --- /dev/null +++ b/.drafts/convenience-graft.md @@ -0,0 +1,107 @@ + + +# Drafts — Artefact.graft convenience wrapper + +Drafts only. Branch corresponding to issue #20. Per `.claude/settings.json` +no `git commit / push / add` was run. + +--- + +## Draft commit message — `artefact` + +``` +feat(artefact): pipeline-friendly graft/3 + +Artefact.graft(left, args, opts \\ []) is a convenience wrapper for +extending an existing artefact with new nodes and relationships +declared inline (same shape as Artefact.new accepts) — without +constructing a second artefact: + + 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 is a keyword list with :nodes and :relationships, identical in +shape to Artefact.new's inline form — except 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 conflicts, + position untouched. Same primary-wins pattern as do_harmonise. +- Adds a new node (uuid not in left). Receives a fresh sequential id + continuing left's offset. + +Args relationships use args-local atom keys, like Artefact.new. Every +key referenced by a relationship must be declared in args.nodes; +otherwise ArgumentError. Relationship dedupe with the existing left +relationships uses the same {from_id, type, to_id} key trick already +used by harmonise and compose, with left winning on properties. + +opts honours :title and :description only — both name the result. +:base_label is NOT honoured; the result keeps left.base_label. + +Provenance: :grafted with the calling module, a summary of left, and +right: %{title: opts[:title], description: opts[:description]} — the +result's name as provided. Distinct source from :composed and +:harmonised. + +Test fixture lives at test/support/our_shells_fixture.ex, adapted from +diffo-dev/.github/livebook/shells.livemd. Loaded by test_helper.exs +via Code.require_file (no mix.exs touch). +``` + +## Notes for the next yarn + +- **No find, only bind.** This was the key spec clarification — combine + uses Binding.find/2 to discover shared uuids automatically. Graft + refuses to do that. The author writes the uuid; the uuid is the + contract. Easier to read what a graft step is doing, harder to fool + yourself with accidental shared uuids. + +- **Bind-only nodes carrying labels/properties get merged left-wins.** + We considered three options (silently drop, raise, merge). Settled on + merge with left winning, matching the do_harmonise primary-wins + pattern. So passing extra labels in a bind-only entry IS valid — it + unions them in. Useful when grafting introduces a new perspective on + an already-known node. + +- **No :base_label override.** Graft can't change the identity-shape + of left. If you want a new base_label, do the work in a fresh + artefact and combine instead. We deliberately tightened opts to + :title and :description only. + +- **Provenance shape is asymmetric.** Left gets the full summary + (title, base_label, uuid, provenance). Right just gets {title, + description} — what was provided in opts. There's no "right artefact" + to summarise; args is a graph fragment, not a named thing. If we + ever want to capture the args graph in provenance (node count, + uuid lists), that's a separate decision. + +- **`mix format` and `mix test` not run from the agent sandbox** — + Elixir/mix isn't installed there. Run locally: + + cd artefact + mix format + mix test + + Tests added: 17 new tests across 5 describe blocks + ("happy path with OurShells fixture", "opts behaviour", + "bind-only merge semantics", "relationship dedupe", "guards"). + +- **Test fixture as `Artefact.new` form, not Arrows JSON.** Per the + yarn — fixtures live at `test/support/our_shells_fixture.ex`. + `mix.exs` configures `elixirc_paths: ["lib", "test/support"]` for + the `:test` env so the fixture compiles automatically (and avoids + the `:test_load_filters` warning that scans `test/` for stray + non-`*_test.exs` files). Standard Phoenix-style pattern; future + fixtures drop into the same dir. + +--- + +*Held in the commons.* 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..7641089 100644 --- a/artefact/mix.exs +++ b/artefact/mix.exs @@ -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 From 6a9884a7313deaf952bd51ef584016bfa3290909 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Tue, 5 May 2026 14:25:24 +0930 Subject: [PATCH 2/3] cleanup --- .drafts/convenience-combine.md | 58 ------------ .drafts/convenience-graft.md | 107 ---------------------- .drafts/mermaid-export.md | 156 --------------------------------- .gitignore | 2 +- 4 files changed, 1 insertion(+), 322 deletions(-) delete mode 100644 .drafts/convenience-combine.md delete mode 100644 .drafts/convenience-graft.md delete mode 100644 .drafts/mermaid-export.md 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/convenience-graft.md b/.drafts/convenience-graft.md deleted file mode 100644 index 55c692e..0000000 --- a/.drafts/convenience-graft.md +++ /dev/null @@ -1,107 +0,0 @@ - - -# Drafts — Artefact.graft convenience wrapper - -Drafts only. Branch corresponding to issue #20. Per `.claude/settings.json` -no `git commit / push / add` was run. - ---- - -## Draft commit message — `artefact` - -``` -feat(artefact): pipeline-friendly graft/3 - -Artefact.graft(left, args, opts \\ []) is a convenience wrapper for -extending an existing artefact with new nodes and relationships -declared inline (same shape as Artefact.new accepts) — without -constructing a second artefact: - - 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 is a keyword list with :nodes and :relationships, identical in -shape to Artefact.new's inline form — except 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 conflicts, - position untouched. Same primary-wins pattern as do_harmonise. -- Adds a new node (uuid not in left). Receives a fresh sequential id - continuing left's offset. - -Args relationships use args-local atom keys, like Artefact.new. Every -key referenced by a relationship must be declared in args.nodes; -otherwise ArgumentError. Relationship dedupe with the existing left -relationships uses the same {from_id, type, to_id} key trick already -used by harmonise and compose, with left winning on properties. - -opts honours :title and :description only — both name the result. -:base_label is NOT honoured; the result keeps left.base_label. - -Provenance: :grafted with the calling module, a summary of left, and -right: %{title: opts[:title], description: opts[:description]} — the -result's name as provided. Distinct source from :composed and -:harmonised. - -Test fixture lives at test/support/our_shells_fixture.ex, adapted from -diffo-dev/.github/livebook/shells.livemd. Loaded by test_helper.exs -via Code.require_file (no mix.exs touch). -``` - -## Notes for the next yarn - -- **No find, only bind.** This was the key spec clarification — combine - uses Binding.find/2 to discover shared uuids automatically. Graft - refuses to do that. The author writes the uuid; the uuid is the - contract. Easier to read what a graft step is doing, harder to fool - yourself with accidental shared uuids. - -- **Bind-only nodes carrying labels/properties get merged left-wins.** - We considered three options (silently drop, raise, merge). Settled on - merge with left winning, matching the do_harmonise primary-wins - pattern. So passing extra labels in a bind-only entry IS valid — it - unions them in. Useful when grafting introduces a new perspective on - an already-known node. - -- **No :base_label override.** Graft can't change the identity-shape - of left. If you want a new base_label, do the work in a fresh - artefact and combine instead. We deliberately tightened opts to - :title and :description only. - -- **Provenance shape is asymmetric.** Left gets the full summary - (title, base_label, uuid, provenance). Right just gets {title, - description} — what was provided in opts. There's no "right artefact" - to summarise; args is a graph fragment, not a named thing. If we - ever want to capture the args graph in provenance (node count, - uuid lists), that's a separate decision. - -- **`mix format` and `mix test` not run from the agent sandbox** — - Elixir/mix isn't installed there. Run locally: - - cd artefact - mix format - mix test - - Tests added: 17 new tests across 5 describe blocks - ("happy path with OurShells fixture", "opts behaviour", - "bind-only merge semantics", "relationship dedupe", "guards"). - -- **Test fixture as `Artefact.new` form, not Arrows JSON.** Per the - yarn — fixtures live at `test/support/our_shells_fixture.ex`. - `mix.exs` configures `elixirc_paths: ["lib", "test/support"]` for - the `:test` env so the fixture compiles automatically (and avoids - the `:test_load_filters` warning that scans `test/` for stray - non-`*_test.exs` files). Standard Phoenix-style pattern; future - fixtures drop into the same dir. - ---- - -*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 `
` swapped in for the `
`
-
----
-
-## 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

From 7ba6ada64b0d881efe4936bc4edacfaadea8f72e Mon Sep 17 00:00:00 2001
From: Matt Beanland 
Date: Tue, 5 May 2026 14:33:04 +0930
Subject: [PATCH 3/3] release 0.1.4

---
 artefact/CHANGELOG.md | 4 ++++
 artefact/mix.exs      | 2 +-
 2 files changed, 5 insertions(+), 1 deletion(-)

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/mix.exs b/artefact/mix.exs
index 7641089..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