Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline
* `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`
* `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

### Features:
* NBN — AVC, CVC, NniGroup characteristic inheritance and metrics (issue #49):
* 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.
* CVC inherits the upstream NniGroup's `nni_group` characteristic via the `:svlan` assignment (singular).
* NniGroup brings up the typed value of every comprised NNI as `nnis[]` via the `:contains` relationship.
* New `cvc_metrics` characteristic on CVC carries `avcs_count` and `avcs_total_bandwidth` aggregated live over assigned AVCs.
* 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`.
* NBN — NTD brings up assigned UNIs as `unis[]` via the `:port` assignment (issue #49 part 2).
* 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.
* 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`.
* `BandwidthProfile.downstream/1` — atom-to-Mbps mapping used by the metrics aggregation. CVCs are treated as symmetric capacity in this model (satellite asymmetry ignored).

### Refactors (continued):
* `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).
* `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.

## [v0.2.2](https://github.com/diffo-dev/diffo/compare/v0.2.1..v0.2.2) (2026-05-21)

### Maintenance:
Expand Down
9 changes: 5 additions & 4 deletions lib/access/calculations/shelf_total_ports.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ defmodule DiffoExample.Access.Calculations.ShelfTotalPorts do
Sums the `:ports` pool capacity across every card a shelf has assigned
a slot to.

For each outgoing slot-assignment (alias `:slot`, source = shelf), looks
up the assigned card's `AssignableCharacteristic` for the `:ports` pool
and sums `(last - first + 1)` across all of them.
For each outgoing slot-assignment (cards consumer-alias their upstream
Shelf relationship as `:shelf`), looks up the assigned card's
`AssignableCharacteristic` for the `:ports` pool and sums
`(last - first + 1)` across all of them.

Local-to-this-repo for now. Could in time become a more general
diffo-side primitive (`SumPoolCapacityOfAssignees` or similar) once the
Expand All @@ -26,7 +27,7 @@ defmodule DiffoExample.Access.Calculations.ShelfTotalPorts do
Enum.map(records, fn shelf ->
assignments =
Diffo.Provider.AssignmentRelationship
|> Ash.Query.filter_input(source_id: shelf.id, alias: :slot)
|> Ash.Query.filter_input(source_id: shelf.id, alias: :shelf)
|> Ash.read!(domain: Diffo.Provider)

Enum.reduce(assignments, 0, fn assignment, acc ->
Expand Down
15 changes: 8 additions & 7 deletions lib/access/resources/card.ex
Original file line number Diff line number Diff line change
Expand Up @@ -89,20 +89,21 @@ defmodule DiffoExample.Access.Card do
end

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

# The slot number this card occupies on its shelf — the value of the
# shelf's :slots-pool assignment to this card.
# The slot number this card occupies on its shelf — the :value of
# the assignment Card aliases :shelf (its upstream Shelf).
calculate :slot,
{:array, :integer},
{Diffo.Provider.Calculations.FieldFromAssignment, [alias: :slot, field: :value]} do
{Diffo.Provider.Calculations.FieldFromAssignment, [alias: :shelf, field: :value]} do
public? true
end
end
Expand Down
22 changes: 12 additions & 10 deletions lib/access/resources/path.ex
Original file line number Diff line number Diff line change
Expand Up @@ -77,29 +77,31 @@ defmodule DiffoExample.Access.Path do

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

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

# The shelf characteristic value brought up transitively — port to the
# card, then the card's slot to its shelf. Two-hop via [:port, :slot].
# The shelf characteristic value brought up transitively — Path's
# :card alias to the Card, then the Card's :shelf alias to its Shelf.
# Two-hop via [:card, :shelf].
calculate :shelf,
{:array, :map},
{DiffoExample.Calculations.InheritedCharacteristic,
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
[
via: [:port, :slot],
via: [:card, :shelf],
characteristic_module: DiffoExample.Access.ShelfCharacteristic
]} do
public? true
Expand Down
2 changes: 1 addition & 1 deletion lib/access/resources/shelf.ex
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ defmodule DiffoExample.Access.Shelf do
calculate :cards,
{:array, :map},
{DiffoExample.Calculations.ReverseInheritedCharacteristic,
[alias: :slot, characteristic_module: DiffoExample.Access.CardCharacteristic]} do
[alias: :shelf, characteristic_module: DiffoExample.Access.CardCharacteristic]} do
public? true
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#
# SPDX-License-Identifier: MIT

defmodule DiffoExample.Calculations.InheritedCharacteristic do
defmodule DiffoExample.Calculations.InheritedCharacteristicViaAssignment do
@moduledoc """
Brings up a typed characteristic value from an assignment-source instance.

Expand All @@ -11,35 +11,55 @@ defmodule DiffoExample.Calculations.InheritedCharacteristic do
`AssignmentRelationship` by alias to reach source instances, then queries
the typed characteristic resource on each source and returns its `.value`.

Local-to-this-repo for now. Worth yarning upstream as a diffo-side
`inherited_characteristic` DSL declaration backed by a
`Diffo.Provider.Calculations.InheritedCharacteristic` calc, sitting
alongside the existing inherited-place and inherited-party machinery.
Sibling to `InheritedCharacteristicViaRelationship`, which performs the
analogous traversal over `DefinedSimpleRelationship` edges (forward,
source → target). Pick the right calc by the kind of edge being
traversed — assignment vs. relationship.

Local-to-this-repo for now. Worth yarning upstream as a pair of
diffo-side DSL declarations backed by analogous calcs in the provider
extension, sitting alongside the existing inherited-place and
inherited-party machinery.

## Options

- `via:` *(required)* — list of alias atoms naming the assignment chain
from this instance back to the source whose characteristic we want.
Each step filters `AssignmentRelationship` by `target_id` and `alias`,
then follows `source_id` to the next set of instances. The aliases are
the assignee's slot names, supplied when the assignment is made.
the **consumer's name for the upstream related resource** at each hop
(e.g. AVC names its CVC slot `:cvc`, CVC names its NniGroup slot
`:nni_group`) — set when the assignment is made.
- `characteristic_module:` *(required)* — the typed characteristic Ash
resource on the final source (e.g. `ShelfCharacteristic`). The calc
queries this resource by `instance_id` and returns the `.value`.
- `singular?:` *(optional, default `false`)* — when `true`, unwraps the
result to a single value (or `nil`) rather than a list. Safe whenever
every hop in `via:` traverses an `AssignmentRelationship` with identity
`[:target_id, :alias]` — that guarantees ≤1 source per step, so the
walk yields at most one value. Declare the calc's return type as `:map`
(rather than `{:array, :map}`) when using this option.

## Example

# Card brings up its shelf's typed characteristic via the slot
# assignment the shelf made to it (alias :slot on the incoming
# AssignmentRelationship).
calculate :shelf, :map,
{DiffoExample.Calculations.InheritedCharacteristic,
calculate :shelf, {:array, :map},
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
[via: [:slot], characteristic_module: ShelfCharacteristic]}

# Path brings up the same via a two-hop chain — port-then-slot.
calculate :shelf, :map,
{DiffoExample.Calculations.InheritedCharacteristic,
calculate :shelf, {:array, :map},
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
[via: [:port, :slot], characteristic_module: ShelfCharacteristic]}

# AVC brings up its singular CVC via its :cvc consumer-alias on the
# cvlan assignment from the CVC. AssignmentRelationship identity
# guarantees ≤1 source, so we declare :map and ask the calc to unwrap.
calculate :cvc, :map,
{DiffoExample.Calculations.InheritedCharacteristicViaAssignment,
[via: [:cvc], characteristic_module: CvcCharacteristic, singular?: true]}
"""
use Ash.Resource.Calculation

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

Enum.map(records, fn record ->
final_ids =
Expand All @@ -62,13 +83,16 @@ defmodule DiffoExample.Calculations.InheritedCharacteristic do
end)
end)

Enum.flat_map(final_ids, fn id ->
characteristic_module
|> Ash.Query.filter_input(instance_id: id)
|> Ash.Query.load(:value)
|> Ash.read!()
|> Enum.map(& &1.value)
end)
values =
Enum.flat_map(final_ids, fn id ->
characteristic_module
|> Ash.Query.filter_input(instance_id: id)
|> Ash.Query.load(:value)
|> Ash.read!()
|> Enum.map(& &1.value)
end)

if singular?, do: List.first(values), else: values
end)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors>
#
# SPDX-License-Identifier: MIT

defmodule DiffoExample.Calculations.InheritedCharacteristicViaRelationship do
@moduledoc """
Brings up typed characteristic values from target instances reached via
forward `Diffo.Provider.Relationship` edges (source → target), optionally
filtered by `type:` and/or `alias:`.

Sibling to `InheritedCharacteristicViaAssignment`, which performs the
analogous traversal over `AssignmentRelationship` edges. Pick the right
calc by the kind of edge being traversed — relationship vs. assignment.

Use this when the edge between the consuming instance and the target was
created by a `:relate` action (a `Provider.Relationship` record). Use
`InheritedCharacteristicViaAssignment` when the edge was created by the
Assigner (an `AssignmentRelationship` record).

Local-to-this-repo for now. Worth yarning upstream alongside the
assignment variant as a pair of provider-side calcs.

## Options

- `characteristic_module:` *(required)* — the typed characteristic Ash
resource on the final source (e.g. `NniCharacteristic`). The calc
queries this resource by `instance_id` and returns the `.value`.
- `type:` *(optional)* — filter relationships by type atom (e.g. `:contains`).
- `alias:` *(optional)* — filter relationships by alias atom (e.g. `:avc`).
- `then_via:` *(optional)* — list of consumer-alias atoms to walk back
via `AssignmentRelationship` **after** the relationship hop. Each step
walks back through the target's incoming assignments (`target_id +
alias` identity, so each step has cardinality ≤1). Aliases name the
upstream related resource each consumer is part of. Use this for mixed
paths — one relationship hop followed by one or more assignment hops
— e.g. PRI's `:cvc` bring-up: `:circuit` owns relationship, then `:cvc`
assignment back to the CVC.
- `singular?:` *(optional, default `false`)* — unwrap to a single value
when the consumer expects a 1-cardinality result (e.g. PRI's `:avc` or
`:uni` aliased owns-relationship). Declare the calc's return type as
`:map` (rather than `{:array, :map}`) when using this option.

## Examples

# NniGroup brings up the typed characteristic of every NNI it
# comprises — forward traversal of :contains relationships, returns
# a list of NniCharacteristic values.
calculate :nnis, {:array, :map},
{DiffoExample.Calculations.InheritedCharacteristicViaRelationship,
[type: :contains, characteristic_module: NniCharacteristic]}

# PRI brings up the singular AVC it owns — PRI calls this related
# resource :circuit (its domain role), set as the alias on PRI's
# owns relationship.
calculate :avc, :map,
{DiffoExample.Calculations.InheritedCharacteristicViaRelationship,
[alias: :circuit, characteristic_module: AvcCharacteristic, singular?: true]}

# PRI brings up the singular CVC two-hop — :circuit owns relationship
# from PRI to AVC, then back via the AVC's :cvc consumer-alias
# assignment from CVC.
calculate :cvc, :map,
{DiffoExample.Calculations.InheritedCharacteristicViaRelationship,
[alias: :circuit, then_via: [:cvc],
characteristic_module: CvcCharacteristic, singular?: true]}
"""
use Ash.Resource.Calculation
require Ash.Query

@impl true
def load(_query, _opts, _context), do: []

@impl true
def calculate(records, opts, _context) do
characteristic_module = Keyword.fetch!(opts, :characteristic_module)
type_filter = Keyword.get(opts, :type)
alias_filter = Keyword.get(opts, :alias)
then_via = Keyword.get(opts, :then_via, [])
singular? = Keyword.get(opts, :singular?, false)

Enum.map(records, fn record ->
target_ids =
Diffo.Provider.Relationship
|> filter_relationships(record.id, type_filter, alias_filter)
|> Ash.read!(domain: Diffo.Provider)
|> Enum.map(& &1.target_id)

final_ids = walk_assignments(target_ids, then_via)

values =
Enum.flat_map(final_ids, fn id ->
characteristic_module
|> Ash.Query.filter_input(instance_id: id)
|> Ash.Query.load(:value)
|> Ash.read!()
|> Enum.map(& &1.value)
end)

if singular?, do: List.first(values), else: values
end)
end

# Walks back through incoming `AssignmentRelationship` records for each
# id, following `target_id + alias` (identity, ≤1 source per step).
defp walk_assignments(ids, []), do: ids

defp walk_assignments(ids, [alias_step | rest]) do
next_ids =
Enum.flat_map(ids, fn id ->
Diffo.Provider.AssignmentRelationship
|> Ash.Query.filter_input(target_id: id, alias: alias_step)
|> Ash.read!(domain: Diffo.Provider)
|> Enum.map(& &1.source_id)
end)

walk_assignments(next_ids, rest)
end

defp filter_relationships(query, source_id, nil, nil),
do: Ash.Query.filter_input(query, source_id: source_id)

defp filter_relationships(query, source_id, type, nil),
do: Ash.Query.filter_input(query, source_id: source_id, type: type)

defp filter_relationships(query, source_id, nil, alias_name),
do: Ash.Query.filter_input(query, source_id: source_id, alias: alias_name)

defp filter_relationships(query, source_id, type, alias_name),
do: Ash.Query.filter_input(query, source_id: source_id, type: type, alias: alias_name)
end
Loading
Loading