Skip to content
Open
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
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
42 changes: 42 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,35 @@
**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)

def _apply_shorthand(port):
# Set both the default and the (override) value so the
# shorthand behaves identically to an explicit per-port
# element_names: readable via ``port.element_names`` AND
# serialized (MDF reads the parameter default).
port.defaults.element_names = list(shorthand)
port.parameters.element_names.set(list(shorthand), override=True)
_validate_element_names_length(port)

if (getattr(self, 'input_ports', None)
and self.input_ports[0].element_names is None):
_apply_shorthand(self.input_ports[0])
if (getattr(self, 'output_ports', None)
and self.output_ports[0].element_names is None):
_apply_shorthand(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
Loading