"""
An Argument is the basic element of an argumentation graph.
This code is mostly based on the work of Benoît Alcaraz.
"""
import inspect
from typing import Callable, Any, List
[docs]
class Argument:
"""
An Argument is the basic element of an Argumentation Graph.
Arguments represent something that may be true or false, and which are
linked together by (attack) relationships.
For example, "There are clouds in the sky" is an argument, which attacks
"The weather is nice". If the "clouds" argument is true in a given
situation, it will be difficult to accept the "nice weather" argument as
well.
"""
name: str
"""
The name of an argument, a unique identifier.
It should ideally be a string that intuitively gives a hint of the
argument's content. In the above example "There are clouds in the sky",
``clouds``, ``cloudy``, or even ``cloudy_sky`` are reasonably good names,
but ``arg1`` is not.
"""
content: str
"""
The content of an argument, a long description.
This represents the argument itself, in a human-readable form.
In the above example, "There are clouds in the sky" can be considered the
content. ``"clouds in the sky"`` can be a shorter content, which still
brings the same meaning.
"""
activation_function: Callable[[Any], bool]
"""
The activation function determines if an argument is alive in a situation.
Arguments are defined outside of any context, but need to be evaluated
within a given situation. For example, "clouds in the sky" is a possible
argument, which may be true (*alive*) if there are effectively clouds; or
false (*killed* or *disabled*) otherwise.
An activation function takes a *situation* as parameter, which are left
untyped for flexibility: they usually will be dicts, but could be instances
of specific classes, lists, dictionaries, ...
The function must return a boolean, which indicates the argument's
*aliveness* in the given situation.
For example, assuming the situation is ``s = {'clouds': 4}`` (meaning that
there are 4 clouds in the sky), an activation function for "clouds in the
sky" could be ``lambda s: s['clouds'] > 0``.
"""
alive: bool
"""
Whether the argument is currently alive.
The aliveness of an argument is determined by the :py:attr:`~activation_function`
in a given situation.
All arguments are alive by default, and should be updated when a situation
is evaluated. They then should be reset after the judgment.
"""
support: List[str]
"""
The list of decisions this argument supports.
This is a legacy from the AFDM, where the goal was to select a decision
to make, supported by arguments. For example, "clouds in the sky" supports
decision "take an umbrella", whereas "nice weather" supports "do not take
an umbrella".
In our case (AFJD), we simply want to judge whether the learning agent's
action was aligned with a moral value; decisions can be simplified to
``'moral'`` (i.e., "yes, the action was aligned") and ``'immoral'`` (i.e.,
"no, the action violates the moral value"), which is a binary choice.
We keep the original definition, a list of decisions to support potential
extensions, such as using different ethical principles to reason over
moral values; yet, in practice, this list of decisions is currently limited.
See also its counterpart, :py:attr:`~.counter`.
"""
counter: List[str]
"""
The list of decisions this argument counters.
Similarly to :py:attr:`~.support`, this is a legacy from the AFDM.
For example, "nice weather" counters decision "take an umbrella".
Note that an argument can be neutral w.r.t. to a decision: it is not because
it does not support it that it *must* counter it.
For example, "clouds in the sky" does not necessarily counter "do not take
an umbrella".
"""
[docs]
def __init__(self,
name: str,
content: str = "",
activation_function: Callable[[Any], bool] = None,
support: List[str] = None,
counter: List[str] = None,
):
"""
Create a new Argument.
:param name: The name (unique identifier) of the argument.
:param content: The (long) description of the argument.
:param activation_function: The activation function of the argument,
typically a ``lambda`` expression that takes a state as parameter,
and returns a boolean. By default (``None``), the argument will
always be considered activated (*alive*).
:param support: The list of decisions that are supported by this
argument. By default, an empty list.
:param counter: The list of decisions that are countered by this
argument. By default, an empty list.
"""
self.name = name
self.content = content
if activation_function is None:
activation_function = lambda s: True
self.activation_function = activation_function
self.alive = True
if support is None:
support = []
self.support = support
if counter is None:
counter = []
self.counter = counter
[docs]
def compute(self, state) -> bool:
"""
Determine whether the argument is activated in a given state.
:param state: The given state. Typically, a dict indexed by strings.
:return: A boolean indicating whether the argument is activated.
"""
return self.activation_function(state)
[docs]
def set_alive(self, alive: bool):
"""Change the argument's aliveness."""
self.alive = alive
[docs]
def add_support(self, value: str):
"""Add a new value to the supports, if it is not present already."""
# value = value.lower()
if value not in self.support:
self.support.append(value)
[docs]
def add_counter(self, value: str):
"""Add a new value to the counters, if it is not present already."""
# value = value.lower()
if value not in self.counter:
self.counter.append(value)
def __str__(self):
"""
Short string representation of an Argument: contains its name.
"""
return f'<Argument {self.name}>'
def __repr__(self):
"""
Long string representation of an Argument: contains all data.
"""
# The `activation_function` can be a bit tricky to represent, as it is
# code. We can use the `inspect` module, but we need to parse the result
# a bit. It will be better than using `str` or `repr`, but not perfect...
# In particular, there may be some left-over characters after the code,
# such as `,` or `)`, depending on how and where the lambda is defined.
# Yet, it can help identifying where is the function truly defined for
# further debugging.
try:
code = inspect.getsourcelines(self.activation_function)
# Source code is the first element of the tuple. We want the
# first line of the source code.
code = code[0][0]
# Can be `def my_function(s):`, in which case we remove `def ` to
# only get the function's name;
# or `(...) lambda s: (...)`, with the first `(...)` being useless
# here, and the second (...) being the actual code, in which case
# we only want what is after the `lambda` keyword.
pos = code.find('def')
if pos != -1:
# `def ` is in the code; let us skip these 4 characters
code = code[pos+4:]
pos = code.find('lambda')
if pos != -1:
# `lambda` is in the code; we want to retain only what is after
code = code[pos:]
# In any case, strip the spaces/newlines
code = code.strip()
except:
# Something failed, let's not crash the app just for this, resort
# to a more naïve string representation.
code = str(self.activation_function)
return f'<Argument name={self.name}; ' \
f'content={self.content}; ' \
f'activation_function={code}; ' \
f'alive={self.alive}; ' \
f'support={self.support}; ' \
f'counter={self.counter}>'