%% Agent Sensor Behaviour. %% %% This module defines the behaviour for sensors that read from the environment %% and produce input values for the agent's neural network. %% %% A sensor answers the question: "WHAT can this agent perceive?" %% %% == Overview == %% %% Sensors are the input side of an agent's sensorimotor interface. Each sensor: %% %% %% The total number of sensor inputs across all registered sensors must match %% the `Inputs' value in the agent's `network_topology()'. %% %% == Implementing a Sensor == %% %% ``` %% -module(vision_sensor). %% -behaviour(agent_sensor). %% %% -export([name/0, input_count/0, read/2]). %% %% name() -> <<"vision">>. %% %% %% 6 rays × 3 channels (agent, food, wall) = 18 inputs %% input_count() -> 18. %% %% read(AgentState, EnvState) -> %% Hex = maps:get(hex, AgentState), %% Agents = maps:get(agents, EnvState), %% Food = maps:get(food, EnvState), %% Walls = maps:get(walls, EnvState), %% Radius = maps:get(arena_radius, EnvState), %% %% %% Cast 6 rays and return 18 values %% cast_rays(Hex, Agents, Food, Walls, Radius). %% ''' %% %% == Standard Sensor Categories == %% %% Agents typically have multiple sensors: %% %% %% == Normalization == %% %% Sensor values should be normalized to the range [0.0, 1.0] for best %% neural network performance. Use inverse-linear scaling for distances: %% `1.0 / (1.0 + Distance)' to convert unbounded distances to [0, 1]. %% %% == Latent Capabilities == %% %% Register all potential sensors upfront, even if initially disabled. %% TWEANN topology mutations can evolve connections to "activate" sensors. %% A sensor with all-zero outputs effectively becomes dormant until evolved. %% %% @author Macula.io %% @copyright 2025 Macula.io %% @see agent_definition %% @see agent_actuator %% @see agent_bridge -module(agent_sensor). %%% ============================================================================ %%% Types %%% ============================================================================ -type sensor_name() :: binary(). %% Unique identifier for the sensor type within an agent. %% Examples: `<<"vision">>', `<<"hearing">>', `<<"energy">>'. -type input_count() :: pos_integer(). %% Number of neural network input nodes this sensor provides. %% Must be positive (at least 1 input). -type sensor_values() :: [float()]. %% List of sensor readings, length must match `input_count/0'. %% Values should be normalized to [0.0, 1.0] range. -type agent_state() :: map(). %% Agent-specific state containing position, energy, etc. %% Structure is domain-defined. -type env_state() :: map(). %% Environment state containing world information. %% Structure is domain-defined. -export_type([ sensor_name/0, input_count/0, sensor_values/0, agent_state/0, env_state/0 ]). %%% ============================================================================ %%% Behaviour Callbacks %%% ============================================================================ %% Returns the unique name of this sensor. %% %% The name should be descriptive and unique within the agent. %% It is used for logging, metrics, and configuration. %% %% Example: %% ``` %% name() -> <<"vision">>. %% ''' -callback name() -> sensor_name(). %% Returns the number of input nodes this sensor provides. %% %% This value is used to: %% %% %% Example: %% ``` %% %% Vision sensor: 6 directions × 3 channels = 18 inputs %% input_count() -> 18. %% ''' -callback input_count() -> input_count(). %% Reads sensory data and returns normalized values. %% %% This is the core sensing function. It: %% %% %% Values should be normalized to [0.0, 1.0] for optimal neural network %% performance. Common normalization strategies: %% %% %% Example: %% ``` %% read(AgentState, EnvState) -> %% Energy = maps:get(energy, AgentState), %% MaxEnergy = maps:get(max_energy, EnvState, 100.0), %% [Energy / MaxEnergy]. %% Single normalized energy value %% ''' -callback read(AgentState, EnvState) -> sensor_values() when AgentState :: agent_state(), EnvState :: env_state(). %%% ============================================================================ %%% API Functions %%% ============================================================================ -export([ validate/1, get_info/1, validate_values/2 ]). %% Validates that a module correctly implements the agent_sensor behaviour. %% %% Checks: %% %% %% Note: Cannot validate read/2 without actual state arguments. %% %% Returns `ok' if valid, or `{error, Reasons}' with a list of validation errors. -spec validate(Module) -> ok | {error, [Reason]} when Module :: module(), Reason :: term(). validate(Module) -> Checks = [ fun() -> validate_exports(Module) end, fun() -> validate_name(Module) end, fun() -> validate_input_count(Module) end ], Errors = lists:filtermap( fun(Check) -> case Check() of ok -> false; {error, Reason} -> {true, Reason} end end, Checks ), case Errors of [] -> ok; _ -> {error, Errors} end. %% Retrieves sensor info from a module. %% %% Returns a map with name and input_count if the module is valid. %% Returns `{error, Reason}' if the module doesn't properly implement the behaviour. -spec get_info(Module) -> {ok, Info} | {error, Reason} when Module :: module(), Info :: #{ name := sensor_name(), input_count := input_count() }, Reason :: term(). get_info(Module) -> case validate(Module) of ok -> Name = Module:name(), InputCount = Module:input_count(), {ok, #{ name => Name, input_count => InputCount }}; {error, _} = Error -> Error end. %% Validates that sensor values match the declared input count. %% %% Checks: %% %% %% Returns `ok' or `{error, Reason}'. -spec validate_values(Module, Values) -> ok | {error, Reason} when Module :: module(), Values :: sensor_values(), Reason :: term(). validate_values(Module, Values) when is_list(Values) -> ExpectedCount = Module:input_count(), ActualCount = length(Values), case ActualCount =:= ExpectedCount of false -> {error, {value_count_mismatch, #{expected => ExpectedCount, actual => ActualCount}}}; true -> case lists:all(fun is_number/1, Values) of true -> ok; false -> {error, {non_numeric_values, Values}} end end; validate_values(_Module, Values) -> {error, {values_not_list, Values}}. %%% ============================================================================ %%% Internal Functions %%% ============================================================================ %% @private validate_exports(Module) -> RequiredExports = [{name, 0}, {input_count, 0}, {read, 2}], Exports = Module:module_info(exports), Missing = [F || F <- RequiredExports, not lists:member(F, Exports)], case Missing of [] -> ok; _ -> {error, {missing_exports, Missing}} end. %% @private validate_name(Module) -> try Module:name() of Name when is_binary(Name), byte_size(Name) > 0 -> ok; Name when is_binary(Name) -> {error, {invalid_name, empty_binary}}; Other -> {error, {invalid_name, {expected_binary, Other}}} catch _:Reason -> {error, {name_callback_failed, Reason}} end. %% @private validate_input_count(Module) -> try Module:input_count() of Count when is_integer(Count), Count > 0 -> ok; Count when is_integer(Count) -> {error, {invalid_input_count, {must_be_positive, Count}}}; Other -> {error, {invalid_input_count, {expected_integer, Other}}} catch _:Reason -> {error, {input_count_callback_failed, Reason}} end.