Skip to content

🔴 unaiverse.networking.node.profile

What this module does 🔴

Defines NodeProfile, which collects and validates a node's static, dynamic, and CV metadata (OS, memory, peer ID, identity fields) for the peer-to-peer network.

profile

█████ █████ ██████ █████ █████ █████ █████ ██████████ ███████████ █████████ ██████████ ░░███ ░░███ ░░██████ ░░███ ░░███ ░░███ ░░███ ░░███░░░░░█░░███░░░░░███ ███░░░░░███░░███░░░░░█ ░███ ░███ ░███░███ ░███ ██████ ░███ ░███ ░███ ░███ █ ░ ░███ ░███ ░███ ░░░ ░███ █ ░ ░███ ░███ ░███░░███░███ ░░░░░███ ░███ ░███ ░███ ░██████ ░██████████ ░░█████████ ░██████
░███ ░███ ░███ ░░██████ ███████ ░███ ░░███ ███ ░███░░█ ░███░░░░░███ ░░░░░░░░███ ░███░░█
░███ ░███ ░███ ░░█████ ███░░███ ░███ ░░░█████░ ░███ ░ █ ░███ ░███ ███ ░███ ░███ ░ █ ░░████████ █████ ░░█████░░████████ █████ ░░███ ██████████ █████ █████░░█████████ ██████████ ░░░░░░░░ ░░░░░ ░░░░░ ░░░░░░░░ ░░░░░ ░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░░░░░ ░░░░░░░░░░ A Collectionless AI Project (https://collectionless.ai) Registration/Login: https://unaiverse.io Code Repositories: https://github.com/collectionlessai/ Main Developers: Stefano Melacci (Project Leader), Christian Di Maio, Tommaso Guidi

NodeProfile

NodeProfile(static: dict, dynamic: dict, cv: list)

Profile information for a UNaIVERSE node, combining static identity, dynamic system state, and CV data.

A NodeProfile is the canonical representation of a peer's identity and runtime state within the UNaIVERSE network. It is created when a node starts up and is continuously updated to reflect changes in system resources, network addresses, and connection status. Profiles are exchanged between peers during the handshake phase so that each side knows the other's capabilities and identity.

The profile is divided into three sections:

  • static: fields that do not change during a node's lifetime (node ID, type, owner identity, creation date, and so on).
  • dynamic: fields that may change at runtime (IP address, peer addresses, memory usage, connection state, world summary, and similar).
  • cv: a chronologically sorted list of achievement/badge dictionaries awarded to this node by worlds it has visited.

Internally all data is stored in a single nested dictionary accessible via get_static_profile, get_dynamic_profile, get_cv, and get_all_profile. Only the keys defined in the internal template are accepted for the dynamic section; unknown keys are silently dropped unless they start with tmp_, in which case they are stored as temporary fields.

Note

Use from_dict to reconstruct a NodeProfile from a serialized dictionary received over the network rather than constructing it directly.

Initialize a NodeProfile from static, dynamic, and CV data.

The CV list is sorted by last_edit_utc and each entry's keys are canonically ordered before storage, so that the resulting JSON serialization produces a deterministic hash regardless of insertion order. After all data is merged, _fill_missing_specs is called to populate any dynamic fields that were not supplied by querying the local system (OS, CPU, memory, and public IP).

The dynamic section uses an internal template that defines every accepted key. Only keys present in that template (or prefixed with tmp_) are stored; all other keys in dynamic are silently ignored. Nested dictionaries under connections and world_summary are merged key by key using the same filter: only None-valued template slots are filled from the provided data.

Parameters:

Name Type Description Default
static dict

Dictionary of static profile fields. Must be non-empty and must contain all required keys (node_id, node_type, node_name, node_description, created_utc, name, surname, title, organization, email, max_nr_connections). Optional keys such as certified, allowed_node_ids, world_masters_node_ids, inspector_node_id, location, and location_method may be absent and will be defaulted.

required
dynamic dict

Dictionary of dynamic profile fields. Only keys that match the internal template (or start with tmp_) are stored. Extra keys are dropped without error.

required
cv list

List of CV entry dictionaries. Each entry must contain a last_edit_utc key used for sorting. Entries are sorted and their keys are canonically ordered before storage.

required

Raises:

Type Description
ValueError

If static is empty (falsy).

ValueError

If any required static key (other than certified, allowed_node_ids, world_masters_node_ids, inspector_node_id, location, and location_method) is missing from static.

Source code in unaiverse/networking/node/profile.py
def __init__(self,
             static: dict,
             dynamic: dict,
             cv: list) -> None:
    """Initialize a ``NodeProfile`` from static, dynamic, and CV data.

    The CV list is sorted by ``last_edit_utc`` and each entry's keys are
    canonically ordered before storage, so that the resulting JSON serialization
    produces a deterministic hash regardless of insertion order. After all data is
    merged, ``_fill_missing_specs`` is called to populate any dynamic fields that
    were not supplied by querying the local system (OS, CPU, memory, and public IP).

    The dynamic section uses an internal template that defines every accepted key.
    Only keys present in that template (or prefixed with ``tmp_``) are stored; all
    other keys in ``dynamic`` are silently ignored. Nested dictionaries under
    ``connections`` and ``world_summary`` are merged key by key using the same
    filter: only ``None``-valued template slots are filled from the provided data.

    Args:
        static: Dictionary of static profile fields. Must be non-empty and must
            contain all required keys (``node_id``, ``node_type``, ``node_name``,
            ``node_description``, ``created_utc``, ``name``, ``surname``, ``title``,
            ``organization``, ``email``, ``max_nr_connections``). Optional keys such
            as ``certified``, ``allowed_node_ids``, ``world_masters_node_ids``,
            ``inspector_node_id``, ``location``, and ``location_method`` may be
            absent and will be defaulted.
        dynamic: Dictionary of dynamic profile fields. Only keys that match the
            internal template (or start with ``tmp_``) are stored. Extra keys are
            dropped without error.
        cv: List of CV entry dictionaries. Each entry must contain a
            ``last_edit_utc`` key used for sorting. Entries are sorted and their
            keys are canonically ordered before storage.

    Raises:
        ValueError: If ``static`` is empty (falsy).
        ValueError: If any required static key (other than ``certified``,
            ``allowed_node_ids``, ``world_masters_node_ids``, ``inspector_node_id``,
            ``location``, and ``location_method``) is missing from ``static``.
    """

    # Checking provided data
    if not static:
        raise ValueError("Missing static profile data")

    # Forcing key order (important! otherwise the hash operation will not be consistent with the one on the server)
    cv = [{k: _cv[k] for k in sorted(_cv)} for _cv in sorted(cv, key=lambda x: x['last_edit_utc'])]

    self._profile_data = \
        {
            'static': {
                'node_id': None,
                'node_type': None,
                'node_name': None,
                'node_description': None,
                'created_utc': None,
                'name': None,
                'surname': None,
                'title': None,
                'organization': None,
                'email': None,
                'max_nr_connections': None,
                'allowed_node_ids': None,
                'world_masters_node_ids': None,
                'certified': None,
                'inspector_node_id': None,
                'location_method': None,
                'location': None
            },
            'dynamic': {
                'os': None,
                'cpu_cores': None,
                'logical_cpus': None,
                'memory_gb': None,
                'memory_avail': None,
                'memory_used': None,
                'timestamp': None,
                'public_ip_address': None,
                'guessed_location': None,
                'peer_id': None,
                'peer_addresses': None,
                'private_peer_id': None,
                'private_peer_addresses': None,
                'proc_inputs': None,
                'proc_outputs': None,
                'streams': None,
                'connections': {
                    'public_agents': None,  # List of dict
                    'world_agents': None,  # List of dict
                    'world_masters': None,  # List of dict
                    'world_peer_id': None,  # Str
                    'role': None  # Str
                },
                'world_summary': {
                    "world_title": None,
                    "world_agents": None,
                    "world_masters": None,
                    "world_agents_count": None,
                    "world_masters_count": None,
                    "total_agents": None,
                    "agent_badges_count": None,
                    "agent_badges": None,
                    "streams_count": None
                },
                "world_roles_fsm": None,  # Dict of FSMs for world roles
                "hidden": None
            },
            'cv': cv
        }

    # Checking the presence of basic static profile info
    for k in self._profile_data['static'].keys():

        # Backward compatibility
        if k not in static and k == "location":
            static['location'] = {}
        if k not in static and k == "location_method":
            static['location_method'] = "manual"

        if (k not in static and k != "certified" and
                k != "allowed_node_ids" and k != "world_masters_node_ids" and k != "inspector_node_id"):  # Patch
            raise ValueError("Missing required static profile info: " + str(k))

    # Filling static profile info (there might be more information that the one shown above)
    for k, v in static.items():
        self._profile_data['static'][k] = v

    # Including the provided dynamic info, only considering the expected keys
    # (the provided "dynamic" argument will contain all or just a sub-portion of the expected keys)
    for k, v in dynamic.items():
        if k == 'connections' and v is not None and isinstance(v, dict):
            for kk, vv in v.items():
                if (kk in self._profile_data['dynamic']['connections'] and
                        self._profile_data['dynamic']['connections'][kk] is None):
                    self._profile_data['dynamic']['connections'][kk] = vv
        elif k == 'world_summary' and v is not None and isinstance(v, dict):
            for kk, vv in v.items():
                if (kk in self._profile_data['dynamic']['world_summary'] and
                        self._profile_data['dynamic']['world_summary'][kk] is None):
                    self._profile_data['dynamic']['world_summary'][kk] = vv
        elif k in self._profile_data['dynamic'] and self._profile_data['dynamic'][k] is None:
            self._profile_data['dynamic'][k] = v
        elif k.startswith('tmp_'):
            self._profile_data['dynamic'][k] = v

    # Internally required attributes
    self._profile_last_updated = None  # Will be set by calling _fill_missing_specs or check_and_update_specs
    self._geolocation_cache = {}  # Will be needed to avoid too many IP-related lookups

    # Filling the missing information (machine-level information, specs) that can be automatically extracted
    self._fill_missing_specs()

    # Flag
    self._connections_updated = False

update_cv

update_cv(new_cv: list[dict]) -> None

Replace the stored CV data with a new list of CV entries.

The provided list is stored as-is, without re-sorting or re-ordering keys. Callers that need canonical ordering should pre-sort the list or pass data through __init__ instead.

Parameters:

Name Type Description Default
new_cv list[dict]

The replacement CV data as a list of dictionaries. Each dictionary represents one CV entry (for example a badge record). Passing an empty list effectively clears the CV.

required
Source code in unaiverse/networking/node/profile.py
def update_cv(self, new_cv: list[dict]) -> None:
    """Replace the stored CV data with a new list of CV entries.

    The provided list is stored as-is, without re-sorting or re-ordering keys.
    Callers that need canonical ordering should pre-sort the list or pass data
    through ``__init__`` instead.

    Args:
        new_cv: The replacement CV data as a list of dictionaries. Each
            dictionary represents one CV entry (for example a badge record).
            Passing an empty list effectively clears the CV.
    """
    self._profile_data['cv'] = new_cv

from_dict classmethod

from_dict(combined_data: dict) -> NodeProfile

Create a NodeProfile instance from a combined profile dictionary.

This is the preferred way to reconstruct a NodeProfile from data received over the network or loaded from persistent storage. The dictionary layout must mirror the internal _profile_data structure: a top-level "static" key, a "dynamic" key, and a "cv" key. The __init__ constructor is invoked with those three sub-dictionaries, so all the same validation and auto-fill logic applies.

Parameters:

Name Type Description Default
combined_data dict

A dictionary representing the full node profile. Expected to contain a "static" sub-dictionary (with at least "node_id"), a "dynamic" sub-dictionary, and a "cv" list of entry dictionaries.

required

Returns:

Type Description
NodeProfile

A new NodeProfile instance populated from combined_data.

Raises:

Type Description
ValueError

If "node_id" is absent or empty in the "static" sub-dictionary.

ValueError

If any other required static key is missing (raised by __init__).

Source code in unaiverse/networking/node/profile.py
@classmethod
def from_dict(cls, combined_data: dict) -> 'NodeProfile':
    """Create a ``NodeProfile`` instance from a combined profile dictionary.

    This is the preferred way to reconstruct a ``NodeProfile`` from data received
    over the network or loaded from persistent storage. The dictionary layout must
    mirror the internal ``_profile_data`` structure: a top-level ``"static"`` key,
    a ``"dynamic"`` key, and a ``"cv"`` key. The ``__init__`` constructor is
    invoked with those three sub-dictionaries, so all the same validation and
    auto-fill logic applies.

    Args:
        combined_data: A dictionary representing the full node profile. Expected
            to contain a ``"static"`` sub-dictionary (with at least ``"node_id"``),
            a ``"dynamic"`` sub-dictionary, and a ``"cv"`` list of entry
            dictionaries.

    Returns:
        A new ``NodeProfile`` instance populated from ``combined_data``.

    Raises:
        ValueError: If ``"node_id"`` is absent or empty in the ``"static"``
            sub-dictionary.
        ValueError: If any other required static key is missing (raised by
            ``__init__``).
    """

    # Ensure essential 'node_id' is present
    node_id = combined_data.get('static').get('node_id')
    if not node_id:
        raise ValueError("Input dictionary must contain a 'node_id'.")

    profile_instance = cls(
        static=combined_data['static'],
        dynamic=combined_data['dynamic'],
        cv=combined_data['cv']
    )

    return profile_instance

check_and_update_specs

check_and_update_specs(update_only: bool = True) -> bool

Check current system specs and merge them into the dynamic profile.

When update_only is True (the default), all fields returned by _get_current_specs are unconditionally written into the dynamic profile via a dictionary merge. This is the fast path used during periodic refreshes.

When update_only is False, each spec field is compared against its stored value. Floats are compared with a tolerance of 1e-6 to avoid spurious updates from minor fluctuations. If any field changed, the full set of current specs is merged into the dynamic profile and a summary of the changes is printed via log.print. This mode is more expensive but lets callers react only when hardware state actually changes.

In both modes, _profile_last_updated is set to the current UTC time upon completion.

Parameters:

Name Type Description Default
update_only bool

If True, merges current specs without change detection. If False, performs field-by-field comparison and only merges on change. Defaults to True.

True

Returns:

Type Description
bool

True if at least one spec field changed. Only meaningful when

bool

update_only=False; always False in the fast-path mode.

Source code in unaiverse/networking/node/profile.py
def check_and_update_specs(self, update_only: bool = True) -> bool:
    """Check current system specs and merge them into the dynamic profile.

    When ``update_only`` is ``True`` (the default), all fields returned by
    ``_get_current_specs`` are unconditionally written into the dynamic profile
    via a dictionary merge. This is the fast path used during periodic refreshes.

    When ``update_only`` is ``False``, each spec field is compared against its
    stored value. Floats are compared with a tolerance of ``1e-6`` to avoid
    spurious updates from minor fluctuations. If any field changed, the full set
    of current specs is merged into the dynamic profile and a summary of the
    changes is printed via ``log.print``. This mode is more expensive but lets
    callers react only when hardware state actually changes.

    In both modes, ``_profile_last_updated`` is set to the current UTC time upon
    completion.

    Args:
        update_only: If ``True``, merges current specs without change detection.
            If ``False``, performs field-by-field comparison and only merges on
            change. Defaults to ``True``.

    Returns:
        ``True`` if at least one spec field changed. Only meaningful when
        ``update_only=False``; always ``False`` in the fast-path mode.
    """

    current_specs = self._get_current_specs()
    specs_changed = False

    if update_only:
        self._profile_data['dynamic'] |= current_specs
    else:
        saved_specs = self._profile_data['dynamic'].copy()
        change_details = []

        # Compare current specs with saved specs (ignore timestamp for comparison)
        keys_to_compare = current_specs.keys()

        for key in keys_to_compare:
            if key == 'timestamp':
                continue

            saved_value = saved_specs.get(key)
            current_value = current_specs.get(key)

            # Handle float comparison with tolerance
            if isinstance(saved_value, float) and isinstance(current_value, float):
                if abs(current_value - saved_value) > 1e-6:  # Tolerance for float changes
                    change_details.append(f"{key}: from {saved_value:.2f} to {current_value:.2f}")
                    specs_changed = True

            elif saved_value != current_value:
                change_details.append(f"{key}: from {saved_value} to {current_value}")
                specs_changed = True

        # Comparing total resources (OS, CPU, total RAM/Disk) is more typical for 'specification' changes.
        if specs_changed:
            # Update the specification in the profile data with the new current specs
            self._profile_data['dynamic'] |= current_specs
            change_summary = ", ".join(change_details)
            log.print(f"Specs changed for '{self._profile_data['static']['node_id']}': {change_summary}")

    self._profile_last_updated = datetime.datetime.now(timezone.utc)  # Mark profile as checked/updated

    return specs_changed

get_static_profile

get_static_profile() -> dict

Return the static portion of the profile data.

The returned dictionary is the live internal object, not a copy. Callers should not mutate it directly to avoid corrupting the profile state.

Returns:

Type Description
dict

A dictionary containing static profile fields such as node_id,

dict

node_type, node_name, node_description, created_utc,

dict

name, surname, title, organization, email,

dict

max_nr_connections, and optional fields like certified,

dict

allowed_node_ids, world_masters_node_ids, and location.

Source code in unaiverse/networking/node/profile.py
def get_static_profile(self) -> dict:
    """Return the static portion of the profile data.

    The returned dictionary is the live internal object, not a copy. Callers
    should not mutate it directly to avoid corrupting the profile state.

    Returns:
        A dictionary containing static profile fields such as ``node_id``,
        ``node_type``, ``node_name``, ``node_description``, ``created_utc``,
        ``name``, ``surname``, ``title``, ``organization``, ``email``,
        ``max_nr_connections``, and optional fields like ``certified``,
        ``allowed_node_ids``, ``world_masters_node_ids``, and ``location``.
    """
    return self._profile_data['static']

get_dynamic_profile

get_dynamic_profile() -> dict

Return the dynamic portion of the profile data.

The returned dictionary is the live internal object, not a copy. Several callers (for example set_addresses_in_profile in World) mutate the private_peer_addresses list inside this dictionary in place, relying on the fact that the same list object is shared by reference. Do not replace the dictionary or any of its nested container values.

Returns:

Type Description
dict

A dictionary containing dynamic profile fields including os,

dict

cpu_cores, logical_cpus, memory_gb, memory_avail,

dict

memory_used, timestamp, public_ip_address,

dict

guessed_location, peer_id, peer_addresses,

dict

private_peer_id, private_peer_addresses, proc_inputs,

dict

proc_outputs, streams, connections (nested dict with role

dict

and peer lists), world_summary, world_roles_fsm, and hidden.

Source code in unaiverse/networking/node/profile.py
def get_dynamic_profile(self) -> dict:
    """Return the dynamic portion of the profile data.

    The returned dictionary is the live internal object, not a copy. Several
    callers (for example ``set_addresses_in_profile`` in ``World``) mutate the
    ``private_peer_addresses`` list inside this dictionary in place, relying on
    the fact that the same list object is shared by reference. Do not replace the
    dictionary or any of its nested container values.

    Returns:
        A dictionary containing dynamic profile fields including ``os``,
        ``cpu_cores``, ``logical_cpus``, ``memory_gb``, ``memory_avail``,
        ``memory_used``, ``timestamp``, ``public_ip_address``,
        ``guessed_location``, ``peer_id``, ``peer_addresses``,
        ``private_peer_id``, ``private_peer_addresses``, ``proc_inputs``,
        ``proc_outputs``, ``streams``, ``connections`` (nested dict with role
        and peer lists), ``world_summary``, ``world_roles_fsm``, and ``hidden``.
    """
    return self._profile_data['dynamic']

get_cv

get_cv() -> list

Return the CV data associated with this node profile.

The returned list is the live internal object. Its initial order reflects the sort applied in __init__ (chronological by last_edit_utc), but subsequent calls to update_cv may replace it with an unsorted list.

Returns:

Type Description
list

A list of CV entry dictionaries. Each dictionary represents one awarded

list

badge or achievement record, sorted by last_edit_utc at construction

list

time.

Source code in unaiverse/networking/node/profile.py
def get_cv(self) -> list:
    """Return the CV data associated with this node profile.

    The returned list is the live internal object. Its initial order reflects the
    sort applied in ``__init__`` (chronological by ``last_edit_utc``), but
    subsequent calls to ``update_cv`` may replace it with an unsorted list.

    Returns:
        A list of CV entry dictionaries. Each dictionary represents one awarded
        badge or achievement record, sorted by ``last_edit_utc`` at construction
        time.
    """
    return self._profile_data['cv']

get_all_profile

get_all_profile() -> dict

Return the complete profile data dictionary.

Provides a single-call way to retrieve the full profile for serialization (for example, before JSON-encoding a profile to send over the network). The returned object is the live internal dictionary; callers should not mutate it directly.

Returns:

Type Description
dict

A dictionary with three top-level keys: "static" (see

dict

get_static_profile), "dynamic" (see get_dynamic_profile),

dict

and "cv" (see get_cv).

Source code in unaiverse/networking/node/profile.py
def get_all_profile(self) -> dict:
    """Return the complete profile data dictionary.

    Provides a single-call way to retrieve the full profile for serialization
    (for example, before JSON-encoding a profile to send over the network).
    The returned object is the live internal dictionary; callers should not
    mutate it directly.

    Returns:
        A dictionary with three top-level keys: ``"static"`` (see
        ``get_static_profile``), ``"dynamic"`` (see ``get_dynamic_profile``),
        and ``"cv"`` (see ``get_cv``).
    """
    return self._profile_data

mark_change_in_connections

mark_change_in_connections() -> None

Flag that a connection change has occurred since the last reset.

Sets the internal _connections_updated flag to True. The node infrastructure reads this flag (via connections_changed) to decide whether the dynamic profile must be re-broadcast to the network. Call unmark_change_in_connections to clear the flag after broadcasting.

Source code in unaiverse/networking/node/profile.py
def mark_change_in_connections(self) -> None:
    """Flag that a connection change has occurred since the last reset.

    Sets the internal ``_connections_updated`` flag to ``True``. The node
    infrastructure reads this flag (via ``connections_changed``) to decide
    whether the dynamic profile must be re-broadcast to the network. Call
    ``unmark_change_in_connections`` to clear the flag after broadcasting.
    """
    self._connections_updated = True

unmark_change_in_connections

unmark_change_in_connections() -> None

Clear the connection-change flag.

Sets the internal _connections_updated flag back to False. Typically called by the node infrastructure after the updated dynamic profile has been successfully broadcast, so the flag does not trigger another unnecessary broadcast at the next scheduled instant. See mark_change_in_connections.

Source code in unaiverse/networking/node/profile.py
def unmark_change_in_connections(self) -> None:
    """Clear the connection-change flag.

    Sets the internal ``_connections_updated`` flag back to ``False``. Typically
    called by the node infrastructure after the updated dynamic profile has been
    successfully broadcast, so the flag does not trigger another unnecessary
    broadcast at the next scheduled instant. See ``mark_change_in_connections``.
    """
    self._connections_updated = False

connections_changed

connections_changed() -> bool

Return whether a connection change has been recorded since the last reset.

Reads the internal _connections_updated flag that is set by mark_change_in_connections and cleared by unmark_change_in_connections.

Returns:

Type Description
bool

True if at least one connection change has been flagged since the last

bool

call to unmark_change_in_connections, False otherwise.

Source code in unaiverse/networking/node/profile.py
def connections_changed(self) -> bool:
    """Return whether a connection change has been recorded since the last reset.

    Reads the internal ``_connections_updated`` flag that is set by
    ``mark_change_in_connections`` and cleared by ``unmark_change_in_connections``.

    Returns:
        ``True`` if at least one connection change has been flagged since the last
        call to ``unmark_change_in_connections``, ``False`` otherwise.
    """
    return self._connections_updated

verify_cv_hash

verify_cv_hash(cv_hash: str) -> tuple[bool, tuple[str, str]]

Verify a CV hash against the hash computed from the stored CV data.

The stored CV list is JSON-serialized and hashed with BLAKE2b (16-byte digest) to produce a compact, collision-resistant fingerprint. The provided hash is then compared to this computed value. Because __init__ sorts the CV entries and canonically orders their keys, the same list always produces the same hash regardless of insertion order, making this comparison safe across different serialization paths.

This method is used during the peer handshake to confirm that both sides hold identical CV data without transmitting the full list.

Parameters:

Name Type Description Default
cv_hash str

The hexadecimal hash string received from the remote peer or stored externally, to be compared against the locally computed hash.

required

Returns:

Type Description
bool

A two-element tuple (match, (provided_hash, computed_hash)). match

tuple[str, str]

is True if the hashes are equal; provided_hash and

tuple[bool, tuple[str, str]]

computed_hash are both included for diagnostic logging when they differ.

Source code in unaiverse/networking/node/profile.py
def verify_cv_hash(self, cv_hash: str) -> tuple[bool, tuple[str, str]]:
    """Verify a CV hash against the hash computed from the stored CV data.

    The stored CV list is JSON-serialized and hashed with BLAKE2b (16-byte digest)
    to produce a compact, collision-resistant fingerprint. The provided hash is then
    compared to this computed value. Because ``__init__`` sorts the CV entries and
    canonically orders their keys, the same list always produces the same hash
    regardless of insertion order, making this comparison safe across different
    serialization paths.

    This method is used during the peer handshake to confirm that both sides hold
    identical CV data without transmitting the full list.

    Args:
        cv_hash: The hexadecimal hash string received from the remote peer or
            stored externally, to be compared against the locally computed hash.

    Returns:
        A two-element tuple ``(match, (provided_hash, computed_hash))``. ``match``
        is ``True`` if the hashes are equal; ``provided_hash`` and
        ``computed_hash`` are both included for diagnostic logging when they differ.
    """
    computed_hash = hashlib.blake2b(json.dumps(self._profile_data['cv']).encode("utf-8"),
                                    digest_size=16).hexdigest()
    return cv_hash == computed_hash, (cv_hash, computed_hash)