Skip to content

Commit 390dcb3

Browse files
Merge pull request #52 from diffo-dev/49-pri
49 pri
2 parents b10838c + 2e5d6ff commit 390dcb3

22 files changed

Lines changed: 1276 additions & 60 deletions

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,22 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline
2626
* `DslAccess.qualify_result` now transitions to `:feasibilityChecked` — restores correct TMF form following [diffo#168](https://github.com/diffo-dev/diffo/pull/168) broadening the Assigner lifecycle gate to include `:feasibilityChecked`
2727
* `Util.summarise_characteristics/2` no longer called from tests — typed characteristic + pool records now surface in TMF JSON by default ([diffo#169](https://github.com/diffo-dev/diffo/pull/169)). The projection function is retained in [lib/diffo_example/util.ex](lib/diffo_example/util.ex) for future projection demonstrations. Expected JSON strings updated across `cable`, `card`, `cable`, `path`, `shelf`, `dsl_access`, `nbn_ethernet` tests to reflect the real typed/pool surfacing
2828

29+
### Features:
30+
* NBN — AVC, CVC, NniGroup characteristic inheritance and metrics (issue #49):
31+
* AVC inherits the upstream CVC's `cvc` characteristic via the `:cvlan` assignment (single-hop), and the NniGroup's `nni_group` characteristic transitively via `[:cvlan, :svlan]` (two-hop). Both singular.
32+
* CVC inherits the upstream NniGroup's `nni_group` characteristic via the `:svlan` assignment (singular).
33+
* NniGroup brings up the typed value of every comprised NNI as `nnis[]` via the `:contains` relationship.
34+
* New `cvc_metrics` characteristic on CVC carries `avcs_count` and `avcs_total_bandwidth` aggregated live over assigned AVCs.
35+
* New `nni_group_metrics` characteristic on NniGroup carries `cvcs_count`/`cvcs_total_bandwidth` (demand), `nnis_count`/`nnis_total_bandwidth` (capacity), and `utilization = cvcs_total_bandwidth / nnis_total_bandwidth`.
36+
* NBN — NTD brings up assigned UNIs as `unis[]` via the `:port` assignment (issue #49 part 2).
37+
* NBN — NbnEthernet (PRI) brings up four characteristics surfacing the full delivery chain (issue #49 part 3): `avc` single-hop via the `:circuit` owns relationship, `uni` single-hop via the `:port` owns relationship, `cvc` two-hop via `:circuit` then `:cvc`, and `ntd` two-hop via `:port` then `:ntd`. All singular.
38+
* NBN and Access — consumer-side aliases on assignments and relationships now name the **upstream related resource** the consumer is part of (its domain role), not the slot/thing being received. NBN: AVC sets `:cvc` on its cvlan assignment, CVC sets `:nni_group` on its svlan assignment, UNI sets `:ntd` on its port assignment; PRI's two `:owns` relationships are aliased `:circuit` (AVC) and `:port` (UNI). Access: Card sets `:shelf` on its slot assignment, Path sets `:card` on its port assignment, and `Shelf.cards` filters on `alias: :shelf`. Inheritance walks use these consumer-aliases. Pool/metric aggregations are unaffected — they still filter by `thing`.
39+
* `BandwidthProfile.downstream/1` — atom-to-Mbps mapping used by the metrics aggregation. CVCs are treated as symmetric capacity in this model (satellite asymmetry ignored).
40+
41+
### Refactors (continued):
42+
* `DiffoExample.Calculations.InheritedCharacteristic` renamed to `InheritedCharacteristicViaAssignment`; new sibling `InheritedCharacteristicViaRelationship` traverses `Provider.Relationship` edges (forward source → target). Both calcs accept `singular?:` to unwrap to a single value where graph identity guarantees ≤1 result. `InheritedCharacteristicViaRelationship` also accepts a `then_via:` list of assignment aliases to continue the walk via `AssignmentRelationship` after the relationship hop — covers mixed paths like PRI's `cvc` (relationship + assignment).
43+
* `ReverseInheritedCharacteristic` extended with a `thing:` filter option, complementing the existing `alias:` filter. Source-side aggregations should prefer `thing:` since it's always set from the pool DSL — see the assignment-direction-asymmetry rationale.
44+
2945
## [v0.2.2](https://github.com/diffo-dev/diffo/compare/v0.2.1..v0.2.2) (2026-05-21)
3046

3147
### Maintenance:

lib/access/calculations/shelf_total_ports.ex

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ defmodule DiffoExample.Access.Calculations.ShelfTotalPorts do
77
Sums the `:ports` pool capacity across every card a shelf has assigned
88
a slot to.
99
10-
For each outgoing slot-assignment (alias `:slot`, source = shelf), looks
11-
up the assigned card's `AssignableCharacteristic` for the `:ports` pool
12-
and sums `(last - first + 1)` across all of them.
10+
For each outgoing slot-assignment (cards consumer-alias their upstream
11+
Shelf relationship as `:shelf`), looks up the assigned card's
12+
`AssignableCharacteristic` for the `:ports` pool and sums
13+
`(last - first + 1)` across all of them.
1314
1415
Local-to-this-repo for now. Could in time become a more general
1516
diffo-side primitive (`SumPoolCapacityOfAssignees` or similar) once the
@@ -26,7 +27,7 @@ defmodule DiffoExample.Access.Calculations.ShelfTotalPorts do
2627
Enum.map(records, fn shelf ->
2728
assignments =
2829
Diffo.Provider.AssignmentRelationship
29-
|> Ash.Query.filter_input(source_id: shelf.id, alias: :slot)
30+
|> Ash.Query.filter_input(source_id: shelf.id, alias: :shelf)
3031
|> Ash.read!(domain: Diffo.Provider)
3132

3233
Enum.reduce(assignments, 0, fn assignment, acc ->

lib/access/resources/card.ex

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,20 +89,21 @@ defmodule DiffoExample.Access.Card do
8989
end
9090

9191
calculations do
92-
# The shelf characteristic value brought up from the shelf this card is
93-
# in — derived live via the :slot assignment.
92+
# The shelf characteristic value brought up from the shelf this card
93+
# is part of — Card's :shelf consumer-alias on its slot assignment
94+
# from the Shelf.
9495
calculate :shelf,
9596
{:array, :map},
96-
{DiffoExample.Calculations.InheritedCharacteristic,
97-
[via: [:slot], characteristic_module: DiffoExample.Access.ShelfCharacteristic]} do
97+
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
98+
[via: [:shelf], characteristic_module: DiffoExample.Access.ShelfCharacteristic]} do
9899
public? true
99100
end
100101

101-
# The slot number this card occupies on its shelf — the value of the
102-
# shelf's :slots-pool assignment to this card.
102+
# The slot number this card occupies on its shelf — the :value of
103+
# the assignment Card aliases :shelf (its upstream Shelf).
103104
calculate :slot,
104105
{:array, :integer},
105-
{Diffo.Provider.Calculations.FieldFromAssignment, [alias: :slot, field: :value]} do
106+
{Diffo.Provider.Calculations.FieldFromAssignment, [alias: :shelf, field: :value]} do
106107
public? true
107108
end
108109
end

lib/access/resources/path.ex

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,29 +77,31 @@ defmodule DiffoExample.Access.Path do
7777

7878
calculations do
7979
# The card characteristic value brought up from the card this path is
80-
# assigned a port on — via the :port assignment.
80+
# part of — Path's :card consumer-alias on its port assignment from
81+
# the Card.
8182
calculate :card,
8283
{:array, :map},
83-
{DiffoExample.Calculations.InheritedCharacteristic,
84-
[via: [:port], characteristic_module: DiffoExample.Access.CardCharacteristic]} do
84+
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
85+
[via: [:card], characteristic_module: DiffoExample.Access.CardCharacteristic]} do
8586
public? true
8687
end
8788

88-
# The port number this path occupies on its card — the value of the
89-
# card's :ports-pool assignment to this path.
89+
# The port number this path occupies on its card — the :value of the
90+
# assignment Path aliases :card (its upstream Card).
9091
calculate :port,
9192
{:array, :integer},
92-
{Diffo.Provider.Calculations.FieldFromAssignment, [alias: :port, field: :value]} do
93+
{Diffo.Provider.Calculations.FieldFromAssignment, [alias: :card, field: :value]} do
9394
public? true
9495
end
9596

96-
# The shelf characteristic value brought up transitively — port to the
97-
# card, then the card's slot to its shelf. Two-hop via [:port, :slot].
97+
# The shelf characteristic value brought up transitively — Path's
98+
# :card alias to the Card, then the Card's :shelf alias to its Shelf.
99+
# Two-hop via [:card, :shelf].
98100
calculate :shelf,
99101
{:array, :map},
100-
{DiffoExample.Calculations.InheritedCharacteristic,
102+
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
101103
[
102-
via: [:port, :slot],
104+
via: [:card, :shelf],
103105
characteristic_module: DiffoExample.Access.ShelfCharacteristic
104106
]} do
105107
public? true

lib/access/resources/shelf.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ defmodule DiffoExample.Access.Shelf do
9696
calculate :cards,
9797
{:array, :map},
9898
{DiffoExample.Calculations.ReverseInheritedCharacteristic,
99-
[alias: :slot, characteristic_module: DiffoExample.Access.CardCharacteristic]} do
99+
[alias: :shelf, characteristic_module: DiffoExample.Access.CardCharacteristic]} do
100100
public? true
101101
end
102102

lib/diffo_example/calculations/inherited_characteristic.ex renamed to lib/diffo_example/calculations/inherited_characteristic_via_assignment.ex

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#
33
# SPDX-License-Identifier: MIT
44

5-
defmodule DiffoExample.Calculations.InheritedCharacteristic do
5+
defmodule DiffoExample.Calculations.InheritedCharacteristicViaAssignment do
66
@moduledoc """
77
Brings up a typed characteristic value from an assignment-source instance.
88
@@ -11,35 +11,55 @@ defmodule DiffoExample.Calculations.InheritedCharacteristic do
1111
`AssignmentRelationship` by alias to reach source instances, then queries
1212
the typed characteristic resource on each source and returns its `.value`.
1313
14-
Local-to-this-repo for now. Worth yarning upstream as a diffo-side
15-
`inherited_characteristic` DSL declaration backed by a
16-
`Diffo.Provider.Calculations.InheritedCharacteristic` calc, sitting
17-
alongside the existing inherited-place and inherited-party machinery.
14+
Sibling to `InheritedCharacteristicViaRelationship`, which performs the
15+
analogous traversal over `DefinedSimpleRelationship` edges (forward,
16+
source → target). Pick the right calc by the kind of edge being
17+
traversed — assignment vs. relationship.
18+
19+
Local-to-this-repo for now. Worth yarning upstream as a pair of
20+
diffo-side DSL declarations backed by analogous calcs in the provider
21+
extension, sitting alongside the existing inherited-place and
22+
inherited-party machinery.
1823
1924
## Options
2025
2126
- `via:` *(required)* — list of alias atoms naming the assignment chain
2227
from this instance back to the source whose characteristic we want.
2328
Each step filters `AssignmentRelationship` by `target_id` and `alias`,
2429
then follows `source_id` to the next set of instances. The aliases are
25-
the assignee's slot names, supplied when the assignment is made.
30+
the **consumer's name for the upstream related resource** at each hop
31+
(e.g. AVC names its CVC slot `:cvc`, CVC names its NniGroup slot
32+
`:nni_group`) — set when the assignment is made.
2633
- `characteristic_module:` *(required)* — the typed characteristic Ash
2734
resource on the final source (e.g. `ShelfCharacteristic`). The calc
2835
queries this resource by `instance_id` and returns the `.value`.
36+
- `singular?:` *(optional, default `false`)* — when `true`, unwraps the
37+
result to a single value (or `nil`) rather than a list. Safe whenever
38+
every hop in `via:` traverses an `AssignmentRelationship` with identity
39+
`[:target_id, :alias]` — that guarantees ≤1 source per step, so the
40+
walk yields at most one value. Declare the calc's return type as `:map`
41+
(rather than `{:array, :map}`) when using this option.
2942
3043
## Example
3144
3245
# Card brings up its shelf's typed characteristic via the slot
3346
# assignment the shelf made to it (alias :slot on the incoming
3447
# AssignmentRelationship).
35-
calculate :shelf, :map,
36-
{DiffoExample.Calculations.InheritedCharacteristic,
48+
calculate :shelf, {:array, :map},
49+
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
3750
[via: [:slot], characteristic_module: ShelfCharacteristic]}
3851
3952
# Path brings up the same via a two-hop chain — port-then-slot.
40-
calculate :shelf, :map,
41-
{DiffoExample.Calculations.InheritedCharacteristic,
53+
calculate :shelf, {:array, :map},
54+
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
4255
[via: [:port, :slot], characteristic_module: ShelfCharacteristic]}
56+
57+
# AVC brings up its singular CVC via its :cvc consumer-alias on the
58+
# cvlan assignment from the CVC. AssignmentRelationship identity
59+
# guarantees ≤1 source, so we declare :map and ask the calc to unwrap.
60+
calculate :cvc, :map,
61+
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
62+
[via: [:cvc], characteristic_module: CvcCharacteristic, singular?: true]}
4363
"""
4464
use Ash.Resource.Calculation
4565

@@ -50,6 +70,7 @@ defmodule DiffoExample.Calculations.InheritedCharacteristic do
5070
def calculate(records, opts, _context) do
5171
via = Keyword.fetch!(opts, :via)
5272
characteristic_module = Keyword.fetch!(opts, :characteristic_module)
73+
singular? = Keyword.get(opts, :singular?, false)
5374

5475
Enum.map(records, fn record ->
5576
final_ids =
@@ -62,13 +83,16 @@ defmodule DiffoExample.Calculations.InheritedCharacteristic do
6283
end)
6384
end)
6485

65-
Enum.flat_map(final_ids, fn id ->
66-
characteristic_module
67-
|> Ash.Query.filter_input(instance_id: id)
68-
|> Ash.Query.load(:value)
69-
|> Ash.read!()
70-
|> Enum.map(& &1.value)
71-
end)
86+
values =
87+
Enum.flat_map(final_ids, fn id ->
88+
characteristic_module
89+
|> Ash.Query.filter_input(instance_id: id)
90+
|> Ash.Query.load(:value)
91+
|> Ash.read!()
92+
|> Enum.map(& &1.value)
93+
end)
94+
95+
if singular?, do: List.first(values), else: values
7296
end)
7397
end
7498
end
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do
6+
@moduledoc """
7+
Brings up typed characteristic values from target instances reached via
8+
forward `Diffo.Provider.Relationship` edges (source → target), optionally
9+
filtered by `type:` and/or `alias:`.
10+
11+
Sibling to `InheritedCharacteristicViaAssignment`, which performs the
12+
analogous traversal over `AssignmentRelationship` edges. Pick the right
13+
calc by the kind of edge being traversed — relationship vs. assignment.
14+
15+
Use this when the edge between the consuming instance and the target was
16+
created by a `:relate` action (a `Provider.Relationship` record). Use
17+
`InheritedCharacteristicViaAssignment` when the edge was created by the
18+
Assigner (an `AssignmentRelationship` record).
19+
20+
Local-to-this-repo for now. Worth yarning upstream alongside the
21+
assignment variant as a pair of provider-side calcs.
22+
23+
## Options
24+
25+
- `characteristic_module:` *(required)* — the typed characteristic Ash
26+
resource on the final source (e.g. `NniCharacteristic`). The calc
27+
queries this resource by `instance_id` and returns the `.value`.
28+
- `type:` *(optional)* — filter relationships by type atom (e.g. `:contains`).
29+
- `alias:` *(optional)* — filter relationships by alias atom (e.g. `:avc`).
30+
- `then_via:` *(optional)* — list of consumer-alias atoms to walk back
31+
via `AssignmentRelationship` **after** the relationship hop. Each step
32+
walks back through the target's incoming assignments (`target_id +
33+
alias` identity, so each step has cardinality ≤1). Aliases name the
34+
upstream related resource each consumer is part of. Use this for mixed
35+
paths — one relationship hop followed by one or more assignment hops
36+
— e.g. PRI's `:cvc` bring-up: `:circuit` owns relationship, then `:cvc`
37+
assignment back to the CVC.
38+
- `singular?:` *(optional, default `false`)* — unwrap to a single value
39+
when the consumer expects a 1-cardinality result (e.g. PRI's `:avc` or
40+
`:uni` aliased owns-relationship). Declare the calc's return type as
41+
`:map` (rather than `{:array, :map}`) when using this option.
42+
43+
## Examples
44+
45+
# NniGroup brings up the typed characteristic of every NNI it
46+
# comprises — forward traversal of :contains relationships, returns
47+
# a list of NniCharacteristic values.
48+
calculate :nnis, {:array, :map},
49+
{DiffoExample.Calculations.InheritedCharacteristicViaRelationship,
50+
[type: :contains, characteristic_module: NniCharacteristic]}
51+
52+
# PRI brings up the singular AVC it owns — PRI calls this related
53+
# resource :circuit (its domain role), set as the alias on PRI's
54+
# owns relationship.
55+
calculate :avc, :map,
56+
{DiffoExample.Calculations.InheritedCharacteristicViaRelationship,
57+
[alias: :circuit, characteristic_module: AvcCharacteristic, singular?: true]}
58+
59+
# PRI brings up the singular CVC two-hop — :circuit owns relationship
60+
# from PRI to AVC, then back via the AVC's :cvc consumer-alias
61+
# assignment from CVC.
62+
calculate :cvc, :map,
63+
{DiffoExample.Calculations.InheritedCharacteristicViaRelationship,
64+
[alias: :circuit, then_via: [:cvc],
65+
characteristic_module: CvcCharacteristic, singular?: true]}
66+
"""
67+
use Ash.Resource.Calculation
68+
require Ash.Query
69+
70+
@impl true
71+
def load(_query, _opts, _context), do: []
72+
73+
@impl true
74+
def calculate(records, opts, _context) do
75+
characteristic_module = Keyword.fetch!(opts, :characteristic_module)
76+
type_filter = Keyword.get(opts, :type)
77+
alias_filter = Keyword.get(opts, :alias)
78+
then_via = Keyword.get(opts, :then_via, [])
79+
singular? = Keyword.get(opts, :singular?, false)
80+
81+
Enum.map(records, fn record ->
82+
target_ids =
83+
Diffo.Provider.Relationship
84+
|> filter_relationships(record.id, type_filter, alias_filter)
85+
|> Ash.read!(domain: Diffo.Provider)
86+
|> Enum.map(& &1.target_id)
87+
88+
final_ids = walk_assignments(target_ids, then_via)
89+
90+
values =
91+
Enum.flat_map(final_ids, fn id ->
92+
characteristic_module
93+
|> Ash.Query.filter_input(instance_id: id)
94+
|> Ash.Query.load(:value)
95+
|> Ash.read!()
96+
|> Enum.map(& &1.value)
97+
end)
98+
99+
if singular?, do: List.first(values), else: values
100+
end)
101+
end
102+
103+
# Walks back through incoming `AssignmentRelationship` records for each
104+
# id, following `target_id + alias` (identity, ≤1 source per step).
105+
defp walk_assignments(ids, []), do: ids
106+
107+
defp walk_assignments(ids, [alias_step | rest]) do
108+
next_ids =
109+
Enum.flat_map(ids, fn id ->
110+
Diffo.Provider.AssignmentRelationship
111+
|> Ash.Query.filter_input(target_id: id, alias: alias_step)
112+
|> Ash.read!(domain: Diffo.Provider)
113+
|> Enum.map(& &1.source_id)
114+
end)
115+
116+
walk_assignments(next_ids, rest)
117+
end
118+
119+
defp filter_relationships(query, source_id, nil, nil),
120+
do: Ash.Query.filter_input(query, source_id: source_id)
121+
122+
defp filter_relationships(query, source_id, type, nil),
123+
do: Ash.Query.filter_input(query, source_id: source_id, type: type)
124+
125+
defp filter_relationships(query, source_id, nil, alias_name),
126+
do: Ash.Query.filter_input(query, source_id: source_id, alias: alias_name)
127+
128+
defp filter_relationships(query, source_id, type, alias_name),
129+
do: Ash.Query.filter_input(query, source_id: source_id, type: type, alias: alias_name)
130+
end

0 commit comments

Comments
 (0)