Skip to content

Commit cddae1d

Browse files
Merge pull request #135 from diffo-dev/120-refactor-assigner
120 refactor assigner
2 parents 3ab4e1d + 1d9b43b commit cddae1d

35 files changed

Lines changed: 978 additions & 451 deletions

AGENTS.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ lib/diffo/provider/
2828
info.ex # Runtime introspection via Spark.InfoGenerator
2929
characteristic.ex # Characteristic build helpers
3030
feature.ex # Feature build helpers
31+
pool.ex # Pool struct + create_pools/2 + update_pools/3
3132
instance_role.ex # InstanceRole struct
3233
party_declaration.ex # PartyDeclaration struct
3334
place_declaration.ex # PlaceDeclaration struct
@@ -36,13 +37,19 @@ lib/diffo/provider/
3637
persisters/ # Spark transformers — bake DSL state into module
3738
transformers/ # TransformBehaviour — action argument injection
3839
verifiers/ # Compile-time DSL correctness checks
40+
assigner/
41+
assigner.ex # Diffo.Provider.Assigner — assign/3 (pools do) and assign/4
42+
assignable_characteristic.ex # AssignableCharacteristic — pool bounds + algorithm
43+
assigned_to_relationship.ex # AssignedToRelationship — assignedTo edges (pool/thing/assigned)
3944
base_instance.ex # Ash Fragment for Instance resources
4045
base_party.ex # Ash Fragment for Party resources
4146
base_place.ex # Ash Fragment for Place resources
4247
components/
4348
base_characteristic.ex # Ash Fragment for typed characteristic resources
49+
base_relationship.ex # Ash Fragment for shared Relationship structure
4450
calculations/
4551
characteristic_value.ex # Calculation: builds .Value TypedStruct from record fields
52+
assigned_values.ex # Calculation: returns list of assigned integers for a pool+thing
4653
instance/extension.ex # Thin marker (sections: []) — kind identification
4754
party/extension.ex # Thin marker
4855
place/extension.ex # Thin marker
@@ -89,6 +96,11 @@ provider do
8996
characteristic :ports, {:array, MyApp.PortCharacteristic}
9097
end
9198

99+
pools do
100+
pool :cores, :core # assignable pool; thing name is :core
101+
pool :vlans, :vlan
102+
end
103+
92104
features do
93105
feature :advanced_routing, is_enabled?: false do
94106
characteristic :policy, MyApp.RoutingPolicy
@@ -143,11 +155,31 @@ mix test path/to/test.exs:LINE # single test
143155
mix test --max-failures 5 # stop early
144156
```
145157

158+
## Module naming and Neo4j labels
159+
160+
AshNeo4j derives a node label from the **last segment** of the module name. Two resources
161+
whose names end in the same word get the same label, which causes read collisions.
162+
163+
**Rule:** suffix every resource module with its kind so the last segment is unique:
164+
- Instance resources: `MyApp.Instance.WidgetInstance` (not `MyApp.Instance.Widget`)
165+
- Characteristic resources: `MyApp.Characteristic.WidgetCharacteristic` (not `MyApp.Characteristic.Widget`)
166+
- Party/Place resources: follow the same convention if ambiguity is possible.
167+
168+
E.g. `Diffo.Test.Instance.CardInstance` → label `:CardInstance`,
169+
and `Diffo.Test.Characteristic.CardCharacteristic` → label `:CardCharacteristic` — no collision.
170+
146171
## Common agent mistakes
147172

148173
- Using old `structure do` / top-level `instances do` — use `provider do` only.
149174
- Using `party :role, Type, reference: true` — use `party_ref :role, Type`.
150175
- Using a plain `Ash.TypedStruct` as a `characteristic` DSL target — use a `BaseCharacteristic`-derived resource instead; the TypedStruct belongs in `<Module>.Value`.
176+
- Using `characteristic :name, Diffo.Provider.AssignableCharacteristic` for pools — use `pools do / pool :name, :thing / end` instead.
177+
- Using the removed `AssignableValue` TypedStruct — it no longer exists; use `pools do`.
178+
- Calling `Assigner.assign/4` when a `pools do` declaration exists — prefer `Assigner.assign/3` which looks up the thing automatically.
179+
- Forgetting to call `Pool.update_pools/3` in `:define` actions when the resource has `pools do` — pool bounds (`first`, `last`, `algorithm`) are set here.
180+
- Using `characteristic :pool_name, Diffo.Provider.AssignedToRelationship``AssignedToRelationship` is not a characteristic; use `pools do / pool :name, :thing / end` instead.
181+
- Querying `Diffo.Provider.Relationship` for assignment records — assignment relationships are on `Diffo.Provider.AssignedToRelationship`; access them via `instance.assignments`.
182+
- Filtering `instance.forward_relationships` for `type == :assignedTo` — those records no longer exist there; use `instance.assignments` directly.
151183
- Calling `build_before/1` or `build_after/2` in actions — these run automatically.
152184
- Declaring `:specified_by`, `:features`, `:characteristics` as action arguments.
153185
- Editing `documentation/dsls/DSL-Diffo.Provider.Extension.md` — it is Spark-generated;

documentation/dsls/DSL-Diffo.Provider.Extension.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ the sections relevant to it, and verifiers enforce correct usage.
3030
end
3131
end
3232

33+
pools do
34+
pool :ports, :port
35+
end
36+
3337
parties do
3438
party :provider, MyApp.Provider
3539
party_ref :owner, MyApp.InfrastructureCo
@@ -92,6 +96,8 @@ Provider DSL — structure, roles, and behaviour for this resource kind
9296
* [features](#provider-features)
9397
* feature
9498
* characteristic
99+
* [pools](#provider-pools)
100+
* pool
95101
* [parties](#provider-parties)
96102
* party
97103
* parties
@@ -276,6 +282,50 @@ Adds a Characteristic
276282

277283

278284

285+
### provider.pools
286+
Assignable pools on this Instance — each pool maps to an AssignableCharacteristic
287+
288+
### Nested DSLs
289+
* [pool](#provider-pools-pool)
290+
291+
292+
### Examples
293+
```
294+
pools do
295+
pool :ports, :port
296+
end
297+
298+
```
299+
300+
301+
302+
303+
### provider.pools.pool
304+
```elixir
305+
pool name, thing
306+
```
307+
308+
309+
Declares an assignable pool — a named range of values for auto-assignment
310+
311+
312+
313+
314+
315+
### Arguments
316+
317+
| Name | Type | Default | Docs |
318+
|------|------|---------|------|
319+
| [`name`](#provider-pools-pool-name){: #provider-pools-pool-name .spark-required} | `atom` | | The pool name (matches the AssignableCharacteristic name). |
320+
| [`thing`](#provider-pools-pool-thing){: #provider-pools-pool-thing .spark-required} | `atom` | | The name of the thing being assigned within the pool (e.g. :port). |
321+
322+
323+
324+
325+
326+
327+
328+
279329
### provider.parties
280330
Party roles on this resource — `party`/`parties`/`party_ref` for Instance kinds; `role` for Party and Place kinds
281331

documentation/how_to/use_diffo_provider_extension.livemd

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ The id is a stable UUID4, the same in every environment for this Instance kind.
141141

142142
**`features do`** — optional capabilities with their own typed characteristic payload.
143143

144+
**`pools do`** — assignable pools for partial resource allocation. Each `pool :name, :thing` declaration creates an `AssignableCharacteristic` node during `build` and generates `pools/0` / `pool/1` on the module. Pool bounds (`first`, `last`, `algorithm`, `assignable_type`) are set in a `:define` action via `Pool.update_pools/3`. Assignment actions use `Assigner.assign/3` — the thing name is looked up from the pool declaration.
145+
144146
**`parties do`** — party roles: `party` (singular), `parties` (plural), `party_ref` (reference, no direct edge).
145147

146148
**`places do`** — place roles: `place` (singular), `places` (plural), `place_ref` (reference).
@@ -151,7 +153,7 @@ arguments automatically onto that action.
151153

152154
Each characteristic is a dedicated Ash resource using the `Diffo.Provider.BaseCharacteristic` fragment. It carries direct typed attributes and a `:value` calculation that builds a companion `<Module>.Value` TypedStruct for ordered JSON encoding. The TypedStruct uses [AshJason.TypedStruct](https://hexdocs.pm/ash_jason/) to control field order in the JSON output.
153155

154-
For partial resource allocation and assignment we've created Diffo.Provider.Assigner. It is used by the host resource, which declares a characteristic with a `Diffo.Provider.AssignableValue` TypedStruct. Allocation is managed within the Provider domain using this characteristic. Assignment to Services or Resources is via 'reverse' type: "assignedTo" relationships enriched by relationship characteristics.
156+
For partial resource allocation and assignment we've created `Diffo.Provider.Assigner`. The host resource declares a `pools do` section listing each assignable pool by name and the kind of thing being assigned. Pool bounds (first/last value, algorithm) are set via a `:define` action. Each assignment is stored as a `Diffo.Provider.AssignedToRelationship` node (Neo4j label `:AssignmentRelationship`) carrying `pool`, `thing`, and the `assigned` value. These are distinct from regular TMF `Diffo.Provider.Relationship` nodes and are accessible on an instance via `instance.assignments`.
155157

156158
Let's imagine a Compute domain which operates GPU and NPU resources. We want to expose a Cluster composite resource which can be dynamically composed of a number of GPU and NPU cores.
157159

@@ -381,7 +383,7 @@ defmodule Diffo.Compute.GpuCharacteristic.Value do
381383
end
382384
```
383385

384-
The GPU resource declares `GpuCharacteristic` for the typed `:gpu` slot and keeps `AssignableValue` for the `:cores` allocation pool (the assigner still uses the dynamic characteristic pattern). The `update :define` action now only needs to handle the dynamic `:cores` update — the typed `:gpu` characteristic is updated directly on the characteristic resource:
386+
The GPU resource declares `GpuCharacteristic` for the typed `:gpu` slot and uses `pools do` to declare the `:cores` assignable pool. The `update :define` action updates both the typed characteristic and the pool bounds. The `update :assign_core` action uses `Assigner.assign/3` — the thing name (`:core`) is looked up from the pool declaration automatically:
385387

386388
```elixir
387389
defmodule Diffo.Compute.GPU do
@@ -391,10 +393,10 @@ defmodule Diffo.Compute.GPU do
391393

392394
alias Diffo.Provider.BaseInstance
393395
alias Diffo.Provider.Instance.Relationship
394-
alias Diffo.Provider.Instance.Characteristic
396+
alias Diffo.Provider.Extension.Characteristic
397+
alias Diffo.Provider.Extension.Pool
395398
alias Diffo.Provider.Assigner
396399
alias Diffo.Provider.Assignment
397-
alias Diffo.Provider.AssignableValue
398400
alias Diffo.Compute
399401
alias Diffo.Compute.GpuCharacteristic
400402

@@ -418,7 +420,10 @@ defmodule Diffo.Compute.GPU do
418420

419421
characteristics do
420422
characteristic :gpu, GpuCharacteristic
421-
characteristic :cores, AssignableValue
423+
end
424+
425+
pools do
426+
pool :cores, :core
422427
end
423428

424429
behaviour do
@@ -442,11 +447,13 @@ defmodule Diffo.Compute.GPU do
442447
end
443448

444449
update :define do
445-
description "allocates the GPU cores (AssignableValue)"
450+
description "sets GPU identity and allocates the cores pool"
446451
argument :characteristic_value_updates, {:array, :term}
447452

448453
change after_action(fn changeset, result, _context ->
449-
with {:ok, result} <- Characteristic.update_values(result, changeset),
454+
with {:ok, result} <-
455+
Characteristic.update_all(result, changeset, characteristics()),
456+
{:ok, result} <- Pool.update_pools(result, changeset, pools()),
450457
{:ok, result} <- Compute.get_gpu_by_id(result.id),
451458
do: {:ok, result}
452459
end)
@@ -464,19 +471,18 @@ defmodule Diffo.Compute.GPU do
464471
end
465472

466473
update :assign_core do
467-
description "relates the GPU with an instance by assigning a core"
474+
description "assigns a core from this GPU to another instance"
468475
argument :assignment, :struct, constraints: [instance_of: Assignment]
469476

470477
change after_action(fn changeset, result, _context ->
471-
with {:ok, result} <- Assigner.assign(result, changeset, :cores, :core),
478+
with {:ok, result} <- Assigner.assign(result, changeset, :cores),
472479
{:ok, result} <- Compute.get_gpu_by_id(result.id),
473480
do: {:ok, result}
474481
end)
475482
end
476483
end
477484
end
478485
```
479-
```
480486

481487
## Party Extension
482488

@@ -745,27 +751,19 @@ gpu_1 = Compute.build_gpu!(%{name: "GPU 1"})
745751
gpu_2 = Compute.build_gpu!(%{name: "GPU 2"})
746752
```
747753

748-
We set the typed `:gpu` characteristic directly on the characteristic resource, then allocate the `:cores` AssignableValue via `update :define`:
754+
We define each GPU: setting its typed `:gpu` characteristic fields and allocating the `:cores` pool bounds. Both are passed via `characteristic_value_updates` to the `:define` action — `Characteristic.update_all` handles the typed `:gpu` update and `Pool.update_pools` handles the `:cores` pool bounds:
749755

750756
```elixir
751-
# Update the typed GpuCharacteristic on each GPU
752-
[gpu_char_1] = Enum.filter(gpu_1.characteristics, fn c -> c.name == :gpu end)
753-
[gpu_char_2] = Enum.filter(gpu_2.characteristics, fn c -> c.name == :gpu end)
754-
755-
gpu_attrs = %{family: :nvidia, model: "GeForce RTX5090", technology: :blackwell}
756-
Compute.update_gpu_characteristic!(gpu_char_1, gpu_attrs)
757-
Compute.update_gpu_characteristic!(gpu_char_2, gpu_attrs)
758-
```
759-
760-
```elixir
761-
# Allocate the cores pool (AssignableValue — dynamic characteristic)
762-
core_updates = [cores: [first: 1, last: 680, free: 680, assignable_type: "tensor"]]
757+
gpu_attrs = [
758+
gpu: [family: :nvidia, model: "GeForce RTX5090", technology: :blackwell],
759+
cores: [first: 1, last: 680, assignable_type: "tensor"]
760+
]
763761

764-
gpu_1 = Compute.define_gpu!(gpu_1, %{characteristic_value_updates: core_updates})
765-
gpu_2 = Compute.define_gpu!(gpu_2, %{characteristic_value_updates: core_updates})
762+
gpu_1 = Compute.define_gpu!(gpu_1, %{characteristic_value_updates: gpu_attrs})
763+
gpu_2 = Compute.define_gpu!(gpu_2, %{characteristic_value_updates: gpu_attrs})
766764
```
767765

768-
The GPU's `:cores` characteristic is an AssignableValue that tracks how many cores are free (unassigned). We can render one as json:
766+
The `:cores` pool is backed by an `AssignableCharacteristic` node that records the range bounds and algorithm. Free cores are computed at assignment time from the count of existing `AssignmentRelationship` records — there is no stored `free` counter. We can render one as json:
769767

770768
```elixir
771769
Jason.encode!(gpu_1, pretty: true) |> IO.puts
@@ -785,16 +783,16 @@ gpu_1 = Compute.assign_gpu_core!(gpu_1, assignment)
785783
gpu_2 = Compute.assign_gpu_core!(gpu_2, assignment)
786784
```
787785

788-
Now our cluster should have a core from each gpu. Check in the neo4j browser for the type: :assignedTo Relationship from the gpu_1 and gpu_2 to the clusters. There should be four, each with a Relationship Characteristic of core, with a value of the assigned core, e.g. 1, 2.
786+
Now our cluster should have a core from each GPU. Check in the Neo4j browser for `:AssignmentRelationship` nodes from `gpu_1` and `gpu_2` to `cluster_1`. There should be foureach carries `pool: :cores`, `thing: :core`, and the `assigned` integer value (e.g. 1, 2, 3 from gpu_1 and 1 from gpu_2).
789787

790-
Also the gpu will show each assignedTo relationship, since these are forward relationships. These should also show the relationship characteristic:
788+
The GPU's `assignments` hold each assignment, showing the assigned core number in the JSON encoding as a `resourceRelationshipCharacteristic`:
791789

792790
```elixir
793791
Jason.encode!(gpu_1, pretty: true) |> IO.puts
794792
```
795793

796-
Make sure you have a look at it in the neo4j browser. There should be Relationship nodes with a role of :assignedTo from each GPU resource instance to the cluster_1 resource instance. Each Relationship should be defined by a Characteristic with the assigned core number.
797-
There is no central assignment table, rather the relationships ARE the assignments.
794+
Make sure you have a look at it in the neo4j browser. There should be `:AssignmentRelationship` nodes from each GPU resource instance to the `cluster_1` resource instance, each carrying the assigned core number.
795+
There is no central assignment table the `AssignedToRelationship` nodes ARE the assignments. They are separate from the regular `:Relationship` nodes used for TMF service/resource relationships, and are accessible in Elixir via `instance.assignments`.
798796

799797
As an exercise, clone the GPU resource to create an NPU resource and assign some NPU cores from it to your cluster. Check that the assigned NPU cores are unique.
800798

@@ -805,7 +803,9 @@ What happens when I request a specific assignment from an instance to which the
805803

806804
In this tutorial you've used Diffo's unified `provider do` extension to define a Compute domain with:
807805

808-
- A composite Cluster resource with GPU core assignment via `Diffo.Provider.Assigner`
806+
- A composite Cluster resource that receives GPU cores via `Diffo.Provider.Assigner`
807+
- A GPU resource using `pools do` to declare the `:cores` assignable pool — `pool :cores, :core` replaces the old `characteristic :cores, AssignableValue` pattern
808+
- Assignments stored on `Diffo.Provider.AssignedToRelationship` nodes (Neo4j label `:AssignmentRelationship`, distinct from TMF `:Relationship` nodes); accessible via `instance.assignments`
809809
- `Tenant` and `Engineer` Party kinds declared with `provider do` that express which instances they operate and manage
810810
- A `DataCentre` Place kind that declares the instances located at it
811811

lib/diffo/provider.ex

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,19 @@ defmodule Diffo.Provider do
7777
define :delete_relationship, action: :destroy
7878
end
7979

80+
resource Diffo.Provider.AssignedToRelationship do
81+
define :create_assigned_to_relationship, action: :create_assignment
82+
define :get_assigned_to_relationship_by_id, action: :read, get_by: :id
83+
define :delete_assigned_to_relationship, action: :destroy
84+
end
85+
86+
resource Diffo.Provider.AssignableCharacteristic do
87+
define :create_assignable_characteristic, action: :create
88+
define :get_assignable_characteristic_by_id, action: :read, get_by: :id
89+
define :update_assignable_characteristic, action: :update
90+
define :delete_assignable_characteristic, action: :destroy
91+
end
92+
8093
resource Diffo.Provider.Characteristic do
8194
define :create_characteristic, action: :create
8295
define :get_characteristic_by_id, action: :read, get_by: :id

0 commit comments

Comments
 (0)