Commit 64ac30ce authored by Lluis Gifre Renom's avatar Lluis Gifre Renom
Browse files

fix(agentic): compare spectrum by directed link and band

parent b01821f6
Loading
Loading
Loading
Loading
+14 −0
Changes for src/agentic/README.md: 14 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -335,6 +335,20 @@ create a 50 GHz optical service from DC1-TP1 to DC2-TP1
A successful mutating workflow must report the created TFS service UUIDs and
verify that every segment reaches `SERVICESTATUS_ACTIVE`.

### Optical Spectrum Questions

Detailed service retrieval includes a deterministic `spectrum_comparison`
decoded from controller `/settings` rules. It compares allocations within
the same domain, directed optical link and band; reverse directions and
different bands are independent. Missing allocation data marks the comparison
incomplete rather than proving that no conflicts exist.

This comparison checks reported service allocations, not link occupancy or
other reservations. Detailed optical-link retrieval with `include_spectrum`
also provides compact free/unavailable ranges alongside the original maps:
`1` means free and `0` means unavailable. Unavailable slots can include
blocked or unsupported spectrum, not only allocations to active services.

## Tests

Unit tests validate configuration parsing, peer parsing, and deterministic
+10 −2
Changes for src/agentic/service/agents/retrieval.py: 10 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -132,7 +132,12 @@ You execute local service retrieval.

Use query_local_service_headers for normal list/count/summary questions.
Use query_local_detailed_services only for raw, full, UUID, config-rule, or
debug requests. Do not confuse services with links or connections.
debug requests, and for spectrum allocations or overlap checks.
Use spectrum_comparison; report directed link, band and slots together.
Opposite directions and different bands are independent resources.
An incomplete comparison is not proof of no conflict. Service allocations
alone do not verify optical-link occupancy or other reservations.
Do not confuse services with links or connections.
"""

CROSS_DOMAIN_SERVICE_RETRIEVAL_INSTRUCTION = """
@@ -140,7 +145,10 @@ You execute cross-domain service retrieval.

Use query_cross_domain_service_headers for normal list/count/summary questions.
Use query_cross_domain_detailed_services only for raw, full, UUID, config-rule,
or debug requests. Do not confuse services with links or connections.
or debug requests, and for spectrum allocations or overlap checks.
Use spectrum_comparison and compare only within the same domain, directed
link and band. Report all three dimensions and any incomplete checks.
Do not confuse services with links or connections.
"""

LOCAL_CONNECTION_RETRIEVAL_INSTRUCTION = """
+16 −0
Changes for src/agentic/service/agents/single.py: 16 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -76,6 +76,22 @@ For read-only requests:
  identifiers, configuration rules, or full payloads.
- Endpoint ports, service paths and connection details also require detailed
  retrieval. Resolve UUIDs against inventory; do not infer paths from names.
- For service spectrum questions use detailed services and its deterministic
  spectrum_comparison. Report direction AND band beside every slot range.
  R1-R2 and R2-R1 are different resources; never merge their allocations.
  Only compare slots on the same domain, directed link and band. If the check
  is incomplete, say so. Do not claim link occupancy was verified from service
  records alone; retrieve optical-link spectrum for an occupancy question.
- List every relevant service with its exact directed link, band and slots,
  including the reverse direction. Do not say all slot numbers are distinct
  when numbers repeat on independent resources; say no same-resource overlap.
  For overlap checks use a table with Service, Directed link, Band, Slots;
  include singleton groups too, not just groups containing several services.
  Use spectrum_comparison.allocation_table as the basis for that table;
  preserve all matching rows, including single reverse-direction services.
- Optical spectrum maps use 1=free and 0=unavailable. Use spectrum_summary
  for ranges/counts. Zero free slots does NOT mean zero occupancy. Unavailable
  slots may be blocked or unsupported; do not attribute all zeros to services.
- Set include_config_rules or include_spectrum only when the user asks for
  configuration rules or spectrum maps, respectively.
- An empty packet-link inventory does not imply there are no optical links.
+22 −2
Changes for src/agentic/service/tools/granular.py: 22 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -21,6 +21,11 @@ import uuid

from google.adk.tools import FunctionTool

from agentic.service.tools.service_spectrum import (
    summarize_link_spectrum,
    summarize_service_spectrum,
)

from agentic.service.tools.service import (
    delete_cross_domain_optical_service,
    delete_service,
@@ -252,7 +257,20 @@ async def query_detailed_optical_links(
        detail_level="full",
        include_ids=True,
    )
    if not include_spectrum:
    if include_spectrum:
        inventory = copy.deepcopy(inventory)
        inventory["spectrum_interpretation"] = (
            "1 = free, 0 = unavailable. Use spectrum_summary ranges, not "
            "visual inspection of raw maps. Unavailable includes blocked "
            "or unsupported slots, not only service allocations. An empty "
            "map means no slot data, not zero occupancy."
        )
        for domain in inventory.get("domains", {}).values():
            for link in domain.get("optical_links", []):
                link["spectrum_summary"] = summarize_link_spectrum(
                    link.get("optical_details", {})
                )
    else:
        inventory = copy.deepcopy(inventory)
        for domain in inventory.get("domains", {}).values():
            for link in domain.get("optical_links", []):
@@ -277,13 +295,15 @@ async def query_services(scope: str = "all") -> dict:
async def query_detailed_services(scope: str = "all") -> dict:
    """Query detailed services in local, remote, or all domains."""

    return await _list_domain_inventory(
    inventory = await _list_domain_inventory(
        scope=scope,
        resource_kind="services",
        detail_level="full",
        include_config_rules=True,
        include_ids=True,
    )
    inventory["spectrum_comparison"] = summarize_service_spectrum(inventory)
    return inventory


async def query_connections(scope: str = "all") -> dict:
+154 −0
Changes for src/agentic/service/tools/service_spectrum.py: 154 added lines, 0 removed lines.
Original line number Diff line number Diff line
# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Compare reported service allocations, not physical spectrum availability."""

import json
from itertools import combinations


BANDS = {
    "C_BAND": "c_slots", "L_BAND": "l_slots", "S_BAND": "s_slots",
    "c_slots": "c_slots", "l_slots": "l_slots", "s_slots": "s_slots",
}


def slot_ranges(slots):
    ranges = []
    for slot in sorted(set(slots)):
        if ranges and slot == ranges[-1][1] + 1:
            ranges[-1][1] = slot
        else:
            ranges.append([slot, slot])
    return ranges


def summarize_link_spectrum(details):
    result = {}
    for band in ("c_slots", "l_slots", "s_slots"):
        bitmap = details.get(band, {})
        free = [int(key) for key, value in bitmap.items() if value == 1]
        unavailable = [int(key) for key, value in bitmap.items() if value == 0]
        result[band] = {
            "free_ranges": slot_ranges(free),
            "unavailable_ranges": slot_ranges(unavailable),
            "free_count": len(free),
            "unavailable_count": len(unavailable),
            "reported_count": len(bitmap),
        }
    return result


def summarize_service_spectrum(inventory):
    allocations = []
    incomplete = []
    for domain_id, domain in inventory.get("domains", {}).items():
        if domain.get("error"):
            incomplete.append({"domain": domain_id, "reason": "query_failed"})
        for service in domain.get("services", []):
            service_type = service.get("service_type")
            if service_type not in (6, "6", "SERVICETYPE_OPTICAL_CONNECTIVITY"):
                continue
            identity = {
                "domain": domain_id,
                "service_name": service.get("name", ""),
                "service_id": service.get("service_id", {}),
            }
            rules = service.get("service_config", {}).get("config_rules", [])
            settings = None
            try:
                values = [
                    rule["custom"]["resource_value"] for rule in rules
                    if rule.get("custom", {}).get("resource_key") == "/settings"
                ]
                if len(values) != 1:
                    raise ValueError("missing or ambiguous /settings")
                settings = json.loads(values[0])
                band = BANDS.get(settings.get("band_type"))
                links = settings.get("links")
                slots = settings.get("slots")
                if not band or not isinstance(links, list) or not links:
                    raise ValueError("missing band or directed links")
                if not all(isinstance(link, str) and link for link in links):
                    raise ValueError("invalid directed links")
                if not isinstance(slots, list) or not slots:
                    raise ValueError("missing slots")
                if not all(type(slot) is int and slot >= 0 for slot in slots):
                    raise ValueError("invalid slots")
            except (ValueError, TypeError, AttributeError, KeyError) as error:
                incomplete.append({**identity, "reason": str(error)})
                continue
            allocations.append({
                **identity,
                "status": service.get("status", service.get("service_status")),
                "band": band,
                "directed_links": links,
                "path": settings.get("path", []),
                "slots": sorted(set(slots)),
            })

    groups = {}
    for allocation in allocations:
        for link in set(allocation["directed_links"]):
            key = (allocation["domain"], link, allocation["band"])
            groups.setdefault(key, []).append(allocation)
    comparisons = []
    for (domain, link, band), members in sorted(groups.items()):
        overlaps = []
        for first, second in combinations(members, 2):
            overlap = sorted(set(first["slots"]) & set(second["slots"]))
            if overlap:
                overlaps.append({
                    "services": [
                        first["service_name"], second["service_name"],
                    ],
                    "slots": overlap,
                })
        comparisons.append({
            "domain": domain, "directed_link": link, "band": band,
            "allocations": [
                {"service_name": member["service_name"],
                 "slots": member["slots"]} for member in members
            ],
            "overlaps": overlaps,
        })
    rows = ["Domain | Service | Directed links | Band | Slots",
            "--- | --- | --- | --- | ---"]
    for allocation in allocations:
        ranges = ", ".join(
            str(start) if start == end else f"{start}-{end}"
            for start, end in slot_ranges(allocation["slots"])
        )
        rows.append(" | ".join([
            str(allocation["domain"]), allocation["service_name"],
            ", ".join(allocation["directed_links"]),
            allocation["band"], ranges,
        ]))
    return {
        "basis": "Retrieved service /settings allocations only",
        "allocation_table": "\n".join(rows),
        "comparison_key": ["domain", "directed_link", "band"],
        "allocations": allocations,
        "link_comparisons": comparisons,
        "overlap_count": sum(len(row["overlaps"]) for row in comparisons),
        "complete": not incomplete and inventory.get("ok", False),
        "incomplete": incomplete,
        "occupancy_verified": False,
        "interpretation": (
            "Report exact directed links and bands. Reversed links and "
            "different bands are independent resources. No reported overlap "
            "does not verify optical-link occupancy or absence of other "
            "reservations. Missing allocation data makes the check incomplete."
        ),
    }
Loading