<!--
SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors>

SPDX-License-Identifier: MIT
-->

# Diffo Example — NBN Domain

```elixir
Mix.install(
  [
    {:diffo_example, "~> 0.4.0"},
    {:diffo, "~> 0.8.0"},
    {:kino, "~> 0.14"}
  ],
  config: [
    bolty: [
      {Bolt,
       [
         uri: "bolt://localhost:7687",
         auth: [username: "neo4j", password: "password"],
         user_agent: "diffoExampleNbnLivebook/1",
         pool_size: 15,
         max_overflow: 3,
         prefix: :default,
         name: Bolt,
         log: false,
         log_hex: false
       ]}
    ]
  ],
  consolidate_protocols: false
)
```

## Overview

NBN is a fictional, simplified take on Australia's wholesale broadband network. Where [Access](access.md) is a single telco with its own DSL service, **NBN is wholesale** — one physical network shared by many Retail Service Providers (RSPs).

This notebook walks the provisioning flow when an RSP sells an NBN Ethernet access to a subscriber. Two things make NBN richer than Access:

- **Multi-tenancy** — every resource is owned by an RSP and policy-scoped to its owner.
- **Longer delivery chain** — `NbnEthernet` (PRI) owns an `Avc` and a `Uni`. The `Avc` consumes a `:cvlan` from a `Cvc`. The `Cvc` consumes an `:svlan` from an `NniGroup`. The `Uni` consumes a `:port` from an `Ntd`. Every step is a chance to bring upstream context up.

See [nbn.md](nbn.md) for the narrative version including the named-vs-metrics characteristic pattern, the alias convention, and what each consumer inherits.

## Setting up

Connect to Neo4j (running locally on the default port). It is helpful to keep the Neo4j browser open at <http://localhost:7474/browser/> as you go through the cells.

```elixir
AshNeo4j.BoltyHelper.is_connected()
```

**Optional** — clear the database so the scenario builds from a clean slate:

```elixir
AshNeo4j.Neo4jHelper.delete_all()
```

```elixir
alias Diffo.Provider.Assignment
alias Diffo.Provider.Instance.Relationship
alias DiffoExample.Nbn
alias DiffoExample.Nbn.{CvcMetrics, NniGroupMetrics}
```

## The Retail Service Providers

Seed the RSPs and pick one to operate as. Every resource we build will be owned by that RSP and isolated from resources owned by others.

```elixir
DiffoExample.Nbn.Initializer.init()
rsps = Nbn.list_rsps!()
Kino.DataTable.new(rsps, keys: [:id, :short_name, :name, :state])
```

```elixir
rsp_input =
  Kino.Input.select(
    "Operate as RSP",
    Enum.map(rsps, fn rsp -> {rsp.name, Atom.to_string(rsp.short_name)} end)
  )
```

```elixir
actor = Enum.find(rsps, fn rsp -> Atom.to_string(rsp.short_name) == Kino.Input.read(rsp_input) end)
actor
```

## The interconnect geography — POIs and CSAs

Before any service exists, NBN's fixed-line footprint is a set of **Points of Interconnect (POI)** — the handover sites where an RSP's traffic meets the network — each paired 1:1 with the **Connectivity Serving Area (CSA)** it interconnects. `Initializer.init()` above already seeded them.

```elixir
pois = Nbn.list_pois!()
Kino.DataTable.new(pois, keys: [:id, :name, :type])
```

A POI carries a `location` (a point); its CSA carries `bounds` (a polygon). Take Stirling (`5STI`) and the area it serves:

```elixir
poi = Nbn.get_poi_by_id!("5STI")
csa = Nbn.get_csa_by_id!("CSA-5STI")
{poi.name, poi.location.coordinates, length(hd(csa.bounds.coordinates))}
```

The POI → CSA pairing is a `PlaceRef` (role `:interconnects`), reachable from the POI side:

```elixir
Diffo.Provider.list_place_refs_from({:place, "5STI"})
```

To *see* the geography — labels, points and polygons — drop to Neo4j Browser (observation only; note the Ash `id` persists as the node `key` property):

```cypher
MATCH (poi:`Poi` {key: '5STI'})-[:`RELATES`]->(:`PlaceRef`)-[:`RELATES`]->(csa:`Csa`)
RETURN poi, csa;
```

Or every pair at once:

```cypher
MATCH (poi:`Poi`)-[:`RELATES`]->(:`PlaceRef`)-[:`RELATES`]->(csa:`Csa`)
RETURN DISTINCT poi, csa LIMIT 150;
```

> Geographic data derived from NBN Co's fixed-line coverage dataset ([data.gov.au](https://data.gov.au/data/dataset/national-broadband-network), © NBN Co, CC BY 4.0), simplified to coarse convex hulls — see `DiffoExample.Nbn.Geo`. Fixed-wireless and satellite coverage are excluded.

## Customer locations

A customer is somewhere. `Initializer.init()` seeded a handful of real Adelaide-Hills venues — each a `Location` (street address) geo-located by a `LocationPoint` (lat/long). The point is what service qualification runs against; the address is for humans.

```elixir
import Ash.Expr
require Ash.Query

Nbn.list_locations!()
|> Enum.map(fn loc -> %{name: loc.name, street: "#{loc.street_nr} #{loc.street_name}, #{loc.locality} #{loc.postcode}"} end)
|> Kino.DataTable.new()
```

Some `LocationPoint`s stand alone — a lat/long needing comms with no postal address (NBN's non-premise model). Here, temporary connectivity for the Tour Down Under: cabinets at the Stirling Oval and Mylor Oval carparks.

```elixir
Nbn.list_location_points!()
|> Enum.map(fn p -> %{id: p.id, name: p.name, coords: inspect(p.location.coordinates)} end)
|> Kino.DataTable.new()
```

## Service qualification — which CSA, and how far to the POI

Pick a customer location. Service qualification is a spatial question answered as an **Ash expression**: which CSA's `bounds` polygon contains the point? `st_contains` pushes the bounding-box stage down to Neo4j, then checks exact containment.

```elixir
# the Stirling Hotel's point (try Crafers Hotel / Stanley Bridge Tavern too — all qualify)
point = Nbn.get_location_point_by_id!("LOP000998597184")

csa =
  Nbn.Csa
  |> Ash.Query.filter(st_contains(bounds, ^point.location))
  |> Ash.read!()
  |> List.first()

csa && csa.name
```

A qualifying location is served by the CSA's POI — traverse the `PlaceRef` (CSA is the target, the POI the source):

```elixir
poi =
  if csa do
    {:ok, csa} = Ash.load(csa, place_refs: [:source_place])
    ref = Enum.find(csa.place_refs, &(&1.role == :interconnects))
    ref && ref.source_place
  end

poi && %{poi_id: poi.id, poi_name: poi.name}
```

How far is the customer from that interconnect? A geodesic distance, again a pushed-down Ash expression (`st_distance_in_meters`, point to point):

```elixir
if poi do
  [%{m: m}] =
    Nbn.LocationPoint
    |> Ash.Query.filter(id == ^point.id)
    |> Ash.Query.calculate(:m, :float, expr(st_distance_in_meters(location, ^poi.location)))
    |> Ash.read!()

  "#{point.name} is #{round(m)} m from POI #{poi.id}"
end
```

### When SQ misses — how far outside coverage?

A location with no containing CSA isn't served by fixed line. Rather than a bare "no", we can say *by how much* it missed — `st_distance_in_meters` works point-to-polygon, giving the distance to a CSA's edge. Narrow to the few nearest POIs, then measure to their CSAs.

```elixir
# German Arms, Hahndorf — real FTTP, but an island outside our coarse Stirling hull
miss = %Geo.Point{coordinates: {138.8103, -35.0283}, srid: 4326}

hit? = Nbn.Csa |> Ash.Query.filter(st_contains(bounds, ^miss)) |> Ash.read!() |> Enum.any?()

if hit? do
  "covered"
else
  # nearest POIs by point distance, then distance to each one's CSA edge
  nearest_pois =
    Nbn.Poi
    |> Ash.Query.calculate(:m, :float, expr(st_distance_in_meters(location, ^miss)))
    |> Ash.read!()
    |> Enum.sort_by(& &1.m)
    |> Enum.take(6)
    |> Enum.map(& &1.id)

  Nbn.Csa
  |> Ash.Query.filter(id in ^Enum.map(nearest_pois, &("CSA-" <> &1)))
  |> Ash.Query.calculate(:edge_m, :float, expr(st_distance_in_meters(bounds, ^miss)))
  |> Ash.read!()
  |> Enum.sort_by(& &1.edge_m)
  |> List.first()
  |> then(fn csa -> "missed #{csa.name} CSA by #{round(csa.edge_m)} m" end)
end
```

## 1. Shareable infrastructure — NNI Group + NNIs

An RSP builds (or has built for it) shareable infrastructure at each Point of Interconnect: an `NniGroup` containing `Nni`s, and a `Cvc` that terminates at the NNI Group. This gets done once per POI per RSP and serves many customers.

```elixir
{:ok, nni_group} = Nbn.build_nni_group(%{}, actor: actor)

{:ok, nni_group} =
  Nbn.define_nni_group(
    nni_group,
    %{
      characteristic_value_updates: [
        nni_group: [group_name: "SYD-POI-01", location: "Sydney Olympic Park"],
        svlans: [first: 1, last: 4000, assignable_type: "svlan"]
      ]
    },
    actor: actor
  )
```

Add two `Nni`s (the physical interconnect ports) and relate them as `:contains`:

```elixir
nni_ids =
  for {port_id, capacity} <- [{"SYD-01-ETH-1", 10_000}, {"SYD-01-ETH-2", 10_000}] do
    {:ok, nni} = Nbn.build_nni(%{}, actor: actor)

    {:ok, _} =
      Nbn.define_nni(
        nni,
        %{
          characteristic_value_updates: [
            nni: [port_id: port_id, capacity: capacity]
          ]
        },
        actor: actor
      )

    nni.id
  end

{:ok, nni_group} =
  Nbn.relate_nni_group(
    nni_group,
    %{
      relationships:
        Enum.map(nni_ids, fn id ->
          %Relationship{id: id, direction: :forward, type: :contains}
        end)
    },
    actor: actor
  )
```

## 2. Shareable infrastructure — CVC

A `Cvc` takes an `:svlan` from the `NniGroup` and names its upstream `:nni_group` (the consumer-alias names the related resource):

```elixir
{:ok, cvc} = Nbn.build_cvc(%{}, actor: actor)

{:ok, cvc} =
  Nbn.define_cvc(
    cvc,
    %{
      characteristic_value_updates: [
        cvc: [bandwidth: 1000],
        cvlans: [first: 1, last: 4000, assignable_type: "cvlan"]
      ]
    },
    actor: actor
  )

{:ok, _nni_group} =
  Nbn.assign_svlan(
    nni_group,
    %{
      assignment: %Assignment{
        assignee_id: cvc.id,
        alias: :nni_group,
        operation: :auto_assign
      }
    },
    actor: actor
  )
```

## 3. Per-customer infrastructure — NTD + UNI

The `Ntd` is the device installed at the customer premises — NBN-managed, no RSP `actor:` needed. The `Uni` consumes a `:port` from it and names its upstream `:ntd`.

```elixir
{:ok, ntd} = Nbn.build_ntd(%{})

{:ok, ntd} =
  Nbn.define_ntd(ntd, %{
    characteristic_value_updates: [
      ntd: [model: "Sercomm CG4000A", serial_number: "SCOMA1A057A2", technology: :FTTP],
      ports: [first: 1, last: 4, assignable_type: "port"]
    ]
  })

{:ok, uni} = Nbn.build_uni(%{})

{:ok, uni} =
  Nbn.define_uni(uni, %{
    characteristic_value_updates: [
      uni: [port: 1, encapsulation: "DSCP Mapped", technology: :FTTP]
    ]
  })

{:ok, _ntd} =
  Nbn.assign_port(ntd, %{
    assignment: %Assignment{
      assignee_id: uni.id,
      alias: :ntd,
      operation: :auto_assign
    }
  })
```

## 4. The AVC

An `Avc` belongs to one RSP. It consumes a `:cvlan` from the RSP's `Cvc` and names its upstream `:cvc`:

```elixir
{:ok, avc} = Nbn.build_avc(%{}, actor: actor)

{:ok, avc} =
  Nbn.define_avc(
    avc,
    %{
      characteristic_value_updates: [avc: [bandwidth_profile: :home_fast]]
    },
    actor: actor
  )

{:ok, _cvc} =
  Nbn.assign_cvlan(
    cvc,
    %{
      assignment: %Assignment{
        assignee_id: avc.id,
        alias: :cvc,
        operation: :auto_assign
      }
    },
    actor: actor
  )
```

## 5. The NBN Ethernet access (PRI)

The PRI is the service the RSP sells. It owns the `Avc` and the `Uni` via two `:owns` relationships — aliased `:circuit` (the role the AVC plays — the access virtual circuit) and `:port` (the role the UNI plays — the customer's port):

```elixir
{:ok, pri} = Nbn.build_nbn_ethernet(%{}, actor: actor)

{:ok, _pri} =
  Nbn.relate_nbn_ethernet(
    pri,
    %{
      relationships: [
        %Relationship{id: avc.id, direction: :forward, type: :owns, alias: :circuit},
        %Relationship{id: uni.id, direction: :forward, type: :owns, alias: :port}
      ]
    },
    actor: actor
  )
```

## 6. Inheritance — the brought-up delivery chain

Load the PRI with all four inherited characteristics. Each one resolves through the assignment and relationship graph live:

```elixir
{:ok, pri} =
  Nbn.get_nbn_ethernet_by_id(
    pri.id,
    load: [:avc, :uni, :cvc, :ntd],
    actor: actor
  )

%{
  avc: pri.avc,
  uni: pri.uni,
  cvc: pri.cvc,
  ntd: pri.ntd
}
```

Single-hop (`:avc`, `:uni`) goes via the `:circuit` and `:port` owns relationships. Two-hop (`:cvc`, `:ntd`) walks the relationship hop then back through the `:cvc` and `:ntd` assignment aliases. All singular — the AssignmentRelationship's `[target_id, alias]` identity guarantees at most one upstream per hop.

The AVC and CVC can also bring up their own context:

```elixir
{:ok, avc} = Nbn.get_avc_by_id(avc.id, load: [:cvc, :nni_group], actor: actor)

%{
  cvc: avc.cvc,           # single-hop via :cvc
  nni_group: avc.nni_group  # two-hop via [:cvc, :nni_group]
}
```

## 7. Metrics — the local KPIs

Metrics are local-only characteristics that don't propagate. Read the CVC's metrics and the NNI Group's metrics directly:

```elixir
cvc_metrics =
  CvcMetrics
  |> Ash.Query.filter_input(instance_id: cvc.id)
  |> Ash.Query.load(:value)
  |> Ash.read_one!(actor: actor)

cvc_metrics.value
```

```elixir
nni_group_metrics =
  NniGroupMetrics
  |> Ash.Query.filter_input(instance_id: nni_group.id)
  |> Ash.Query.load(:value)
  |> Ash.read_one!(actor: actor)

nni_group_metrics.value
```

`utilization = cvcs_total_bandwidth / nnis_total_bandwidth` — demand over capacity at the NNI Group. Expected 0–1 under normal provisioning; >1 under deliberate oversubscription.

`NniGroup.nnis` brings up the typed values of each contained NNI (low-cardinality, so the list is fine):

```elixir
{:ok, nni_group} = Nbn.get_nni_group_by_id(nni_group.id, load: [:nnis], actor: actor)
nni_group.nnis
```

## 8. TMF JSON

The PRI serialises to TMF-shaped JSON. The metrics characteristic is inline; the `:owns` relationships surface in `resourceRelationship`; the typed characteristics are surfaced post-#169.

```elixir
pri
|> Jason.encode!()
|> Jason.decode!()
|> Jason.encode!(pretty: true)
|> IO.puts()
```

The NNI Group's JSON shows the `:contains` relationships to NNIs alongside the `:assignedTo` relationships to CVCs, the `svlans` pool, and the `metrics` characteristic:

```elixir
nni_group
|> Jason.encode!()
|> Jason.decode!()
|> Jason.encode!(pretty: true)
|> IO.puts()
```

## 9. Lawful intercept — trace a UNI to the network edge

> *Given a customer's UNI (the interface at the premises), which network-edge NNIs could its traffic traverse?*

One `inherited_characteristic` on `Uni` answers it by walking the whole bearer chain in reverse — **UNI → PRI → AVC → CVC → NNI Group → NNIs** — a single declaration mixing relationship and assignment hops and changing direction mid-walk:

<!-- livebook:{"force_markdown":true} -->

```elixir
inherited_characteristic :intercept_nnis,
  via: [
    {:reverse, relationship: [alias: :port]},      # UNI ← its PRI (owns :port)
    {:forward, relationship: [alias: :circuit]},   # PRI → the owned AVC (:circuit)
    {:reverse, assignment: :cvc},                  # AVC ← its CVC (cvlan)
    {:reverse, assignment: :nni_group},            # CVC ← its NNI Group (svlan)
    {:forward, relationship: :contains}            # NNI Group → the NNIs
  ],
  read: :nni
```

`DiffoExample.Nbn.ServiceInitializer` has already seeded (via `Initializer.init()` above) **quokka's** standing edge at the Stirling (5STI) POI — two NNI Groups carrying four CVCs — and **NBN's** on-site NTD at the library with four idle UNIs. Here's the edge:

```elixir
alias DiffoExample.Nbn.ServiceInitializer, as: SI
{:ok, quokka} = Nbn.get_rsp_by_short_name(:quokka)

for {label, id} <- [{"A — 10G", SI.group_ids().group_a}, {"B — 100G", SI.group_ids().group_b}] do
  {:ok, group} = Nbn.get_nni_group_by_id(id, load: [:nnis], actor: quokka)
  %{nni_group: label, nnis: group.nnis |> Enum.map(& &1.port_id) |> Enum.join(", ")}
end
|> Kino.DataTable.new()
```

Now provision a service. **You are quokka** — pick an idle UNI and the CVC to land it on. The CVC choice is the load-balancing decision, and it decides which NNI Group (and so which NNIs) the service rides:

```elixir
intercept_cvc_input =
  Kino.Input.select(
    "CVC — the RSP's load-balancing choice",
    [
      {"NNI Group A (10G) · CVC 1", Enum.at(SI.cvc_ids().group_a, 0)},
      {"NNI Group A (10G) · CVC 2", Enum.at(SI.cvc_ids().group_a, 1)},
      {"NNI Group B (100G) · CVC 1", Enum.at(SI.cvc_ids().group_b, 0)},
      {"NNI Group B (100G) · CVC 2", Enum.at(SI.cvc_ids().group_b, 1)}
    ]
  )
```

```elixir
intercept_uni_input =
  Kino.Input.select(
    "Idle UNI to light up",
    SI.uni_ids()
    |> Enum.with_index(1)
    |> Enum.map(fn {id, port} -> {"UNI on NTD port #{port}", id} end)
  )
```

```elixir
intercept_cvc_id = Kino.Input.read(intercept_cvc_input)
intercept_uni_id = Kino.Input.read(intercept_uni_input)

{:ok, chosen_uni} = Nbn.get_uni_by_id(intercept_uni_id)
{:ok, chosen_cvc} = Nbn.get_cvc_by_id(intercept_cvc_id, actor: quokka)

# The order: land this UNI on that CVC — builds an AVC (cvlan from the CVC) and a
# PRI owning the AVC (:circuit) and the UNI (:port).
pri = SI.provision_service(chosen_uni, chosen_cvc, quokka)

# Now ask the UNI: which NNIs could my traffic traverse?
{:ok, chosen_uni} = Nbn.get_uni_by_id(intercept_uni_id, load: [:intercept_nnis])

chosen_uni.intercept_nnis
|> Enum.map(fn nni -> %{nni: nni.port_id, capacity_mbps: nni.capacity} end)
|> Kino.DataTable.new()
```

Pick a CVC on **Group A** and the answer is the two **10G** NNIs; pick one on **Group B** and it's the two **100G** NNIs — same NTD, same kind of UNI, but the intercept follows the path you actually provisioned. Nothing stored that answer: `intercept_nnis` re-walks the graph on every read, so re-wiring moves it. That liveness is the whole point of the unified `via:` grammar.

> Each run lights a *fresh* idle UNI (one per NTD port) — choose a different port to compare a second path side by side.

### …and the service knows its own geography

The same PRI also surfaces *where it lives*, via `inherited_place` (#65) — the unified `via:` grammar now reaches places too. It walks the bearer to the NNI Group (for the **POI** and its **CSA**) and to the NTD (for the customer's **LocationPoint** and **Location**), reading one place ref on each reached instance. (Place→place hops — POI→CSA, LOP→LOC — aren't expressible yet, so the NNI Group and NTD each carry both places directly; see diffo #227.)

```elixir
{:ok, pri} =
  Nbn.get_nbn_ethernet_by_id(pri.id,
    load: [:poi, :csa, :location_point, :location],
    actor: quokka
  )

[
  %{place: "POI — interconnect", id: pri.poi.id, name: pri.poi.name},
  %{place: "CSA — serving area", id: pri.csa.id, name: pri.csa.name},
  %{place: "LocationPoint — premises point", id: pri.location_point.id, name: pri.location_point.name},
  %{place: "Location — premises address", id: pri.location.id, name: pri.location.name}
]
|> Kino.DataTable.new()
```

## Exploring the graph

```cypher
MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 100;
```

You'll see Specification nodes (one per resource type), Instance nodes (the things we just built), Characteristic nodes (typed and pool), and the assignment / relationship edges between them. Filter by the RSP's stamp to see one tenant's slice.

## What next?

You've used multi-tenancy, the long delivery chain, the named-vs-metrics pattern, the alias convention, and the cross-resource inheritance — every pattern from the [#49 design](https://github.com/diffo-dev/diffo_example/issues/49).

Open [provider.md](provider.md) if you want to revisit the primitives now that you've seen them at scale, or [access.md](access.md) for the simpler single-tenant warm-up.

When you're ready to model your own domain, start with one specification, declare its characteristics, decide whether anything pools, and watch the JSON come out the other side. The structure carries you a long way.
