Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion psyneulink/core/components/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -1578,7 +1578,7 @@ def _get_compilation_params(self):
"enable_output_type_conversion", "changes_shape",
"output_type", "range", "internal_only",
"require_projection_in_composition", "default_input",
"shadow_inputs", "compute_reconfiguration_cost",
"shadow_inputs", "element_names", "compute_reconfiguration_cost",
"reconfiguration_cost", "net_outcome", "outcome",
"enabled_cost_functions", "control_signal_costs",
"default_allocation", "same_seed_for_all_allocations",
Expand Down
34 changes: 34 additions & 0 deletions psyneulink/core/components/mechanisms/mechanism.py
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,18 @@
in which each label (key) specifies a string associated with a value for the OutputPort(s) of the
Mechanism; see `Mechanism_Labels_Dicts` for additional details.

element_names : list of str : default None
Mechanism-level shorthand that assigns semantic labels to each
element of the Mechanism's default (primary) `InputPort` *and*
default `OutputPort`, but only when those ports don't already
carry their own ``element_names``. Equivalent to passing
``element_names=[...]`` to both ports individually; per-port
labels always win if both are supplied. Distinct from
**input_labels** / **output_labels**, which map symbolic names
to value vectors. The list length must equal the size of the
default port's value; a mismatch raises a `PortError` at
construction.

Attributes
----------

Expand Down Expand Up @@ -1719,6 +1731,7 @@
function=None,
output_ports=None,
output_labels=None,
element_names=None,
params=None,
name=None,
prefs=None,
Expand Down Expand Up @@ -1788,6 +1801,27 @@
**kwargs
)

# Mechanism-level ``element_names`` is a shorthand: apply it to the
# default (first) input port AND the default output port whenever
# those ports don't already carry their own element_names. Not the
# same as ``input_labels`` / ``output_labels`` (those are
# symbolic-name -> value-vector dictionaries -- a different concept).
# element_names is a read-only structural Parameter, so the shorthand
# sets it through the Parameters API rather than by attribute.
if element_names:
from psyneulink.core.components.ports.port import (
_validate_element_names_length,
)
Comment on lines +1812 to +1814
shorthand = list(element_names)
if (getattr(self, 'input_ports', None)
and self.input_ports[0].element_names is None):
self.input_ports[0].parameters.element_names.set(shorthand, override=True)
_validate_element_names_length(self.input_ports[0])
if (getattr(self, 'output_ports', None)
and self.output_ports[0].element_names is None):
self.output_ports[0].parameters.element_names.set(shorthand, override=True)
_validate_element_names_length(self.output_ports[0])

# FIX: 10/3/17 - IS THIS CORRECT? SHOULD IT BE INITIALIZED??
self._status = INITIALIZING
self._receivesProcessInput = False
Expand Down
21 changes: 21 additions & 0 deletions psyneulink/core/components/ports/inputport.py
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,15 @@ class InputPort(Port_Base):
specifies whether the InputPort requires external input when its `owner <Port_Base.owner>` is the `INPUT`
`Node <Composition_Nodes>` of a `Composition (see `internal_only <InputPort.internal_only>` for details).

element_names : list of str : default None
optional semantic labels for each element of the InputPort's
`value <InputPort.value>`, surfaced by the `_debugger`
NODE_EXECUTION snapshot and consumed by inspection tools. The
list length must equal the size of the port's value; a mismatch
raises a `PortError` at construction. Stored as a static,
read-only structural `Parameter` that does not participate in
execution.

Attributes
----------

Expand Down Expand Up @@ -778,6 +787,10 @@ class InputPort(Port_Base):
<Registry_Naming>` apply to the InputPorts specified, as well as any that are added to the Mechanism once it
is created (see `note <Port_Naming_Note>`).

element_names : list of str or None
the value passed to the **element_names** argument of the constructor (or ``None`` if unset). See the
argument description for usage and validation rules.

"""

#region CLASS ATTRIBUTES
Expand Down Expand Up @@ -866,6 +879,7 @@ class Parameters(Port_Base.Parameters):
combine = None
internal_only = Parameter(False, stateful=False, loggable=False, pnl_internal=True)
shadow_inputs = Parameter(None, stateful=False, loggable=False, read_only=True, pnl_internal=True, structural=True)
element_names = Parameter(None, stateful=False, loggable=False, read_only=True, structural=True)

def _validate_default_input(self, default_input):
if default_input is not None and default_input is not DEFAULT_VARIABLE:
Expand All @@ -892,8 +906,14 @@ def __init__(self,
name=None,
prefs: Optional[ValidPrefSet] = None,
context=None,
element_names=None,
**kwargs):

# Normalize element_names up front (copy the list; falsy -> None) so
# both the deferred-init capture below and super().__init__() see the
# same normalized value.
element_names = list(element_names) if element_names else None

if variable is None and input_shapes is None and projections is not None:
variable = self._assign_variable_from_projection(variable, input_shapes, projections)

Expand Down Expand Up @@ -931,6 +951,7 @@ def __init__(self,
exponent=exponent,
internal_only=internal_only,
shadow_inputs=None,
element_names=element_names,
params=params,
name=name,
prefs=prefs,
Expand Down
21 changes: 21 additions & 0 deletions psyneulink/core/components/ports/outputport.py
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,15 @@ class OutputPort(Port_Base):
`mod_afferents <Port.mod_afferents>` attributes, respectively (see `OutputPort_Projections` for additional
details).

element_names : list of str : default None
optional semantic labels for each element of the OutputPort's
`value <OutputPort.value>`, surfaced by the `_debugger`
NODE_EXECUTION snapshot and consumed by inspection tools. The
list length must equal the size of the port's value; a mismatch
raises a `PortError` at construction. Stored as a static,
read-only structural `Parameter` that does not participate in
execution.

Attributes
----------

Expand Down Expand Up @@ -885,6 +894,10 @@ class OutputPort(Port_Base):
OutputPorts specified, as well as any that are added to the Mechanism once it is created (see `note
<Port_Naming_Note>`).

element_names : list of str or None
the value passed to the **element_names** argument of the constructor (or ``None`` if unset). See the
argument description for usage and validation rules.

"""

#region CLASS ATTRIBUTES
Expand Down Expand Up @@ -921,6 +934,7 @@ class Parameters(Port_Base.Parameters):
:read only: True
"""
variable = Parameter(np.array([0]), read_only=True, getter=_output_port_variable_getter, pnl_internal=True, constructor_argument='default_variable')
element_names = Parameter(None, stateful=False, loggable=False, read_only=True, structural=True)

#endregion

Expand All @@ -939,10 +953,16 @@ def __init__(self,
prefs: Optional[ValidPrefSet] = None,
index=None,
assign=None,
element_names=None,
**kwargs):

context = kwargs.pop(CONTEXT, None)

# Normalize element_names up front (copy the list; falsy -> None) so
# both the deferred-init capture and super().__init__() see the same
# normalized value.
element_names = list(element_names) if element_names else None

# For backward compatibility with CALCULATE, ASSIGN and INDEX
if 'calculate' in kwargs:
assign = kwargs['calculate']
Expand Down Expand Up @@ -995,6 +1015,7 @@ def __init__(self,
function=function,
index=index,
assign=assign,
element_names=element_names,
**kwargs
)

Expand Down
30 changes: 30 additions & 0 deletions psyneulink/core/components/ports/port.py
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,29 @@ class PortError(ComponentError):
pass


def _validate_element_names_length(port):
# element_names is a flat list of labels, one per element of the
# port's value vector. If the count doesn't match the value's size,
# the labels would silently fall through to numeric indices at
# debug/render time -- almost certainly a typo in the user's model.
# Raise here so the mistake surfaces at construction.
names = getattr(port, "element_names", None)
if not names:
return
try:
value = port.defaults.value
expected_len = int(np.asarray(value).size)
except Exception:
return
if len(names) != expected_len:
raise PortError(
f"element_names for {port.__class__.__name__} "
f"{port.name!r} has {len(names)} entries ({names!r}) but "
f"the port's value has {expected_len} element(s); the "
f"number of labels must match the value's size."
)


# DOCUMENT: INSTANTIATION CREATES AN ATTIRBUTE ON THE OWNER MECHANISM WITH THE PORT'S NAME + VALUE_SUFFIX
# THAT IS UPDATED BY THE PORT'S value setter METHOD (USED BY LOGGING OF MECHANISM ENTRIES)
class Port_Base(Port):
Expand Down Expand Up @@ -1121,6 +1144,13 @@ def __init__(self,

self.projections = self._get_all_projections()

# Validate element_names length now that the port's value shape
# is known. Placed here (rather than in OutputPort/InputPort
# __init__) so the deferred-init replay path -- which routes
# through Port_Base.__init__ directly, bypassing the subclass
# __init__ -- also runs the check.
_validate_element_names_length(self)

if context.source == ContextFlags.COMMAND_LINE:
owner.add_ports([self])

Expand Down
149 changes: 149 additions & 0 deletions tests/misc/test_element_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Tests for the ``element_names`` per-port label feature (#11).

The feature is purely additive: ``Port.element_names`` is ``None`` by default,
gets stored verbatim when explicitly passed, and the ``Mechanism`` constructor
shorthand applies the same list to the default input and output ports when
those don't already carry their own labels. No execution-time behavior
changes; the labels are static construction metadata consumed by downstream
tools (PsyNeuView's pill / Run State / Inspector via the ``_debugger``
snapshot path, plus future MDF serialization).
"""

import pytest

import psyneulink as pnl
from psyneulink.core.components.ports.port import PortError


def test_unset_element_names_default_to_none():
"""No element_names argument anywhere → both ports report None."""
m = pnl.TransferMechanism(input_shapes=[2], name='m')
assert m.input_ports[0].element_names is None
assert m.output_ports[0].element_names is None


def test_mechanism_shorthand_applies_to_default_input_and_output():
"""Mechanism-level ``element_names`` lands on the default input + output port."""
m = pnl.TransferMechanism(
input_shapes=[2],
name='hidden',
element_names=['red', 'green'],
)
assert m.input_ports[0].element_names == ['red', 'green']
assert m.output_ports[0].element_names == ['red', 'green']


def test_explicit_outputport_element_names_persist():
"""An OutputPort constructed with element_names carries them once owned.

element_names is a structural Parameter, so (like every other port
Parameter, e.g. default_input) it is populated when the port is
instantiated into a Mechanism, not while standalone/deferred.
"""
m = pnl.TransferMechanism(
input_shapes=[3],
output_ports=[pnl.OutputPort(name='RESULT', element_names=['a', 'b', 'c'])],
)
assert m.output_ports[0].element_names == ['a', 'b', 'c']


def test_explicit_inputport_element_names_persist():
"""An InputPort constructed with element_names carries them once owned."""
m = pnl.TransferMechanism(
default_variable=[[0, 0]],
input_ports=[pnl.InputPort(name='SRC', element_names=['x', 'y'])],
)
assert m.input_ports[0].element_names == ['x', 'y']


def test_explicit_port_element_names_override_shorthand():
"""Per-port element_names win over the Mechanism-level shorthand.

Important for multi-port architectures where the default input port
and default output port carry different element-level semantics.
"""
m = pnl.TransferMechanism(
input_shapes=[2],
name='m3',
element_names=['shorthand_a', 'shorthand_b'],
output_ports=[pnl.OutputPort(name='RESULT', element_names=['explicit_x', 'explicit_y'])],
)
assert m.output_ports[0].element_names == ['explicit_x', 'explicit_y']


def test_element_names_stored_as_list_not_aliased():
"""A list passed in is copied — caller mutations don't bleed in."""
names = ['a', 'b']
m = pnl.TransferMechanism(
default_variable=[[0, 0]],
input_ports=[pnl.InputPort(name='SRC', element_names=names)],
)
names.append('c')
assert m.input_ports[0].element_names == ['a', 'b']


def test_falsy_element_names_treated_as_unset():
"""Empty list / None → unset (None), consistent with the
"labels are optional" principle. Avoids surfacing zero-length arrays
to downstream tools as if labels were intentionally provided."""
m_none = pnl.TransferMechanism(
default_variable=[[0]],
output_ports=[pnl.OutputPort(name='O', element_names=None)],
)
assert m_none.output_ports[0].element_names is None
m_empty = pnl.TransferMechanism(
default_variable=[[0]],
output_ports=[pnl.OutputPort(name='O', element_names=[])],
)
assert m_empty.output_ports[0].element_names is None


# ---------------------------------------------------------------------------
# Length validation (Phase 1 polish)
# ---------------------------------------------------------------------------


def test_mechanism_shorthand_length_match_ok():
"""Length matches the port's value size → no error."""
m = pnl.TransferMechanism(
input_shapes=[3],
name='ok',
element_names=['a', 'b', 'c'],
)
assert m.input_ports[0].element_names == ['a', 'b', 'c']
assert m.output_ports[0].element_names == ['a', 'b', 'c']


def test_mechanism_shorthand_length_mismatch_raises():
"""Mechanism shorthand with wrong number of labels → PortError."""
with pytest.raises(PortError, match='element_names'):
pnl.TransferMechanism(
input_shapes=[3],
name='bad',
element_names=['only', 'two'],
)


def test_explicit_outputport_length_mismatch_raises():
"""Explicit OutputPort with mismatched element_names → PortError.

Must attach to an owner so the deferred-init path resolves and the
port's value shape is known.
"""
with pytest.raises(PortError, match='element_names'):
pnl.TransferMechanism(
input_shapes=[2],
name='bad_out',
output_ports=[pnl.OutputPort(name='RESULT', element_names=['a', 'b', 'c'])],
)


def test_explicit_inputport_length_mismatch_raises():
"""Explicit InputPort with mismatched element_names → PortError."""
with pytest.raises(PortError, match='element_names'):
pnl.TransferMechanism(
default_variable=[[0, 0]],
name='bad_in',
input_ports=[pnl.InputPort(name='InputPort-0',
element_names=['a', 'b', 'c'])],
)
Loading