Skip to content

Commit c817bf1

Browse files
committed
feat: improve GeniusNegotiator None offer handling and add Scenario.stability
GeniusNegotiator improvements: - Add none_offer_response parameter ("latest" or "best") - New on_none_offer() method for customizable None offer handling - Remove blanket rejection of mechanisms with allow_none_with_data Scenario stability: - Add stability property returning combined stability of all ufuns - Add convenience methods: is_volatile(), is_stationary(), etc. - Add has_stable_min, has_stable_max, has_stable_ordering properties Tests: - Add comprehensive ExtendedOutcome tests in test_outcome_space.py
1 parent eb46ffe commit c817bf1

File tree

3 files changed

+321
-10
lines changed

3 files changed

+321
-10
lines changed

src/negmas/genius/negotiator.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ class GeniusNegotiator(SAONegotiator):
6262
If false, ignore these exceptions and assume a None return.
6363
If None use strict for n_steps limited negotiations and not strict for time_limit
6464
limited ones.
65+
none_offer_response: How to respond when receiving a None offer. Options are:
66+
- "latest": Respond with the latest offer made by this negotiator (default)
67+
- "best": Respond with the best offer according to this negotiator's ufun
68+
If "latest" is selected but no previous offer exists, falls back to "best".
6569
"""
6670

6771
def __init__(
@@ -78,6 +82,7 @@ def __init__(
7882
port: int = DEFAULT_JAVA_PORT,
7983
genius_bridge_path: str | None = None,
8084
strict: bool | None = None,
85+
none_offer_response: str = "latest",
8186
id: str | None = None,
8287
**kwargs,
8388
):
@@ -89,6 +94,7 @@ def __init__(
8994
self.__destroyed = False
9095
self.__started = False
9196
self._strict = strict
97+
self._none_offer_response = none_offer_response
9298
self.capabilities["propose"] = can_propose
9399
self.add_capabilities({"genius": True})
94100
self.genius_bridge_path = (
@@ -300,20 +306,12 @@ def join(
300306
301307
Returns:
302308
True if successfully joined, False otherwise
303-
304-
Raises:
305-
ValueError: If the mechanism allows None offers (allow_none_with_data=True),
306-
which Genius negotiators cannot handle.
307309
"""
308310
if ufun:
309311
preferences = ufun
310312
if not preferences:
311313
preferences = self.__preferences_received
312314

313-
# Check if the mechanism allows None offers - Genius negotiators cannot handle this
314-
if hasattr(nmi, "allow_none_with_data") and nmi.allow_none_with_data:
315-
return False
316-
317315
result = super().join(
318316
nmi=nmi, state=state, preferences=preferences, ufun=None, role=role
319317
)
@@ -731,6 +729,26 @@ def _current_step(self, state):
731729
s = int(s / self.nmi.n_negotiators)
732730
return s
733731

732+
def on_none_offer(self, state: SAOState, source: str | None = None) -> SAOResponse:
733+
"""
734+
Called when the negotiator receives a None offer.
735+
736+
Override this method to customize behavior when a None offer is received.
737+
The default implementation uses `none_offer_response` setting to decide
738+
whether to respond with the latest offer or the best offer.
739+
740+
Args:
741+
state: The current SAO negotiation state
742+
source: The ID of the negotiator who made the None offer
743+
744+
Returns:
745+
SAOResponse with the action and offer to respond with
746+
"""
747+
if self._none_offer_response == "latest" and self.__my_last_offer is not None:
748+
return SAOResponse(ResponseType.REJECT_OFFER, self.__my_last_offer)
749+
# Fall back to best offer (via propose_sao which queries Java agent)
750+
return self.propose_sao(state, source)
751+
734752
def respond_sao(self, state: SAOState, source: str | None = None) -> None:
735753
"""
736754
Processes an incoming offer and prepares a response for SAO mechanisms.
@@ -743,8 +761,10 @@ def respond_sao(self, state: SAOState, source: str | None = None) -> None:
743761
if offer is None and self.__my_last_offer is not None and self._strict:
744762
raise ValueError(f"{self._me()} got counter with a None offer.")
745763
if offer is None:
746-
# TODO: change this to use the correct source if multilateral negotiation
747-
self.propose_sao(state, source)
764+
response = self.on_none_offer(state, source)
765+
self.__my_last_offer = response.outcome
766+
self.__my_last_response = response.response
767+
self.__my_last_offer_step = self._current_step(state)
748768
return
749769
proposer_id = self.nmi.genius_id(state.current_proposer)
750770
current_step = self._current_step(state)

src/negmas/inout.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@
4646
opposition_level,
4747
pareto_frontier,
4848
winwin_level,
49+
Stability,
50+
STATIONARY,
4951
)
5052
from .preferences.value_fun import TableFun
5153

@@ -165,6 +167,75 @@ def is_linear_ufun(ufun: BaseUtilityFunction) -> bool:
165167

166168
return all(is_linear_ufun(u) for u in self.ufuns)
167169

170+
@property
171+
def stability(self) -> Stability:
172+
"""Returns the combined stability of all utility functions in the scenario.
173+
174+
The combined stability is the bitwise AND of all ufun stabilities, meaning
175+
a flag is only set if ALL ufuns have that flag set.
176+
177+
Returns:
178+
Stability flags that are common to all ufuns in the scenario.
179+
180+
Examples:
181+
>>> from negmas import make_issue, Scenario
182+
>>> from negmas.preferences import LinearUtilityFunction, STATIONARY
183+
>>> issues = [make_issue([0, 1, 2], "x")]
184+
>>> u1 = LinearUtilityFunction(weights=[1.0], issues=issues)
185+
>>> u2 = LinearUtilityFunction(weights=[0.5], issues=issues)
186+
>>> scenario = Scenario(outcome_space=make_os(issues), ufuns=(u1, u2))
187+
>>> scenario.stability == STATIONARY
188+
True
189+
"""
190+
if not self.ufuns:
191+
return STATIONARY
192+
result = self.ufuns[0].stability
193+
for ufun in self.ufuns[1:]:
194+
result = Stability(int(result) & int(ufun.stability))
195+
return result
196+
197+
def is_volatile(self) -> bool:
198+
"""Check if the scenario is volatile (any ufun has no stability guarantees).
199+
200+
Returns True if the combined stability is VOLATILE (value = 0).
201+
"""
202+
return self.stability.is_volatile
203+
204+
def is_stationary(self) -> bool:
205+
"""Check if all ufuns in the scenario are fully stationary.
206+
207+
Returns True only if ALL ufuns have ALL stability flags set.
208+
"""
209+
return self.stability.is_stationary
210+
211+
def is_session_dependent(self) -> bool:
212+
"""Check if any ufun depends on session details (NMI)."""
213+
return self.stability.is_session_dependent
214+
215+
def is_state_dependent(self) -> bool:
216+
"""Check if any ufun depends on state variables."""
217+
return self.stability.is_state_dependent
218+
219+
@property
220+
def has_stable_min(self) -> bool:
221+
"""Check if minimum utility is stable for all ufuns."""
222+
return self.stability.has_stable_min
223+
224+
@property
225+
def has_stable_max(self) -> bool:
226+
"""Check if maximum utility is stable for all ufuns."""
227+
return self.stability.has_stable_max
228+
229+
@property
230+
def has_stable_ordering(self) -> bool:
231+
"""Check if outcome ordering is stable for all ufuns."""
232+
return self.stability.has_stable_ordering
233+
234+
@property
235+
def has_stable_diff_ratios(self) -> bool:
236+
"""Check if relative utility differences are stable for all ufuns."""
237+
return self.stability.has_stable_diff_ratios
238+
168239
def plot(
169240
self,
170241
ufun_indices: tuple[int, int] | None = None,

tests/core/test_outcome_space.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,3 +613,223 @@ def test_singleton_os_repr_and_str(self):
613613

614614
assert "1" in repr_str and "2" in repr_str
615615
assert "1" in str_str and "2" in str_str
616+
617+
618+
class TestExtendedOutcome:
619+
"""Tests for ExtendedOutcome class and best_for method."""
620+
621+
def test_extended_outcome_with_only_outcome(self):
622+
"""Test ExtendedOutcome with only an outcome."""
623+
from negmas.outcomes.common import ExtendedOutcome
624+
625+
outcome = (5, 10)
626+
extended = ExtendedOutcome(outcome=outcome)
627+
628+
assert extended.outcome == (5, 10)
629+
assert extended.outcome_space is None
630+
assert extended.data is None
631+
632+
def test_extended_outcome_with_outcome_and_data(self):
633+
"""Test ExtendedOutcome with outcome and data."""
634+
from negmas.outcomes.common import ExtendedOutcome
635+
636+
outcome = (5, 10)
637+
data = {"text": "My offer", "confidence": 0.9}
638+
extended = ExtendedOutcome(outcome=outcome, data=data)
639+
640+
assert extended.outcome == (5, 10)
641+
assert extended.data["text"] == "My offer"
642+
assert extended.data["confidence"] == 0.9
643+
644+
def test_extended_outcome_with_outcome_space(self):
645+
"""Test ExtendedOutcome with only an outcome_space (multiple offers)."""
646+
from negmas.outcomes.common import ExtendedOutcome
647+
648+
issues = [make_issue(5, name="price"), make_issue(3, name="quantity")]
649+
os = make_os(issues)
650+
extended = ExtendedOutcome(outcome=None, outcome_space=os)
651+
652+
assert extended.outcome is None
653+
assert extended.outcome_space is os
654+
655+
def test_extended_outcome_with_both_outcome_and_outcome_space(self):
656+
"""Test ExtendedOutcome with both outcome and outcome_space."""
657+
from negmas.outcomes.common import ExtendedOutcome
658+
659+
issues = [make_issue(5, name="price"), make_issue(3, name="quantity")]
660+
os = make_os(issues)
661+
outcome = (2, 1)
662+
extended = ExtendedOutcome(outcome=outcome, outcome_space=os)
663+
664+
assert extended.outcome == (2, 1)
665+
assert extended.outcome_space is os
666+
667+
def test_best_for_with_only_outcome(self):
668+
"""Test best_for returns the outcome when only outcome is set."""
669+
from negmas.outcomes.common import ExtendedOutcome
670+
from negmas.preferences import LinearUtilityFunction
671+
672+
issues = [make_issue(5, name="price"), make_issue(3, name="quantity")]
673+
ufun = LinearUtilityFunction.random(issues=issues)
674+
675+
outcome = (2, 1)
676+
extended = ExtendedOutcome(outcome=outcome)
677+
678+
result = extended.best_for(ufun)
679+
assert result == outcome
680+
681+
def test_best_for_with_only_outcome_space(self):
682+
"""Test best_for returns the best outcome from outcome_space."""
683+
from negmas.outcomes.common import ExtendedOutcome
684+
from negmas.preferences import LinearUtilityFunction
685+
686+
issues = [make_issue(5, name="price"), make_issue(3, name="quantity")]
687+
os = make_os(issues)
688+
# Use positive weights so higher values are better
689+
ufun = LinearUtilityFunction(weights=[1.0, 1.0], issues=issues)
690+
691+
extended = ExtendedOutcome(outcome=None, outcome_space=os)
692+
693+
result = extended.best_for(ufun)
694+
# With positive weights, best outcome should be (4, 2) - max values
695+
assert result == (4, 2)
696+
# Verify it's actually the best
697+
assert ufun(result) == ufun.max()
698+
699+
def test_best_for_with_both_outcome_and_outcome_space(self):
700+
"""Test best_for returns best from outcome_space when both are set."""
701+
from negmas.outcomes.common import ExtendedOutcome
702+
from negmas.preferences import LinearUtilityFunction
703+
704+
issues = [make_issue(5, name="price"), make_issue(3, name="quantity")]
705+
os = make_os(issues)
706+
# Use positive weights so higher values are better
707+
ufun = LinearUtilityFunction(weights=[1.0, 1.0], issues=issues)
708+
709+
# outcome is NOT the best in the space
710+
outcome = (1, 0)
711+
extended = ExtendedOutcome(outcome=outcome, outcome_space=os)
712+
713+
result = extended.best_for(ufun)
714+
# Should return best from outcome_space, not the provided outcome
715+
assert result == (4, 2)
716+
assert result != outcome
717+
718+
def test_best_for_with_none_outcome_and_none_space(self):
719+
"""Test best_for returns None when both outcome and outcome_space are None."""
720+
from negmas.outcomes.common import ExtendedOutcome
721+
from negmas.preferences import LinearUtilityFunction
722+
723+
issues = [make_issue(5, name="price"), make_issue(3, name="quantity")]
724+
ufun = LinearUtilityFunction.random(issues=issues)
725+
726+
extended = ExtendedOutcome(outcome=None, outcome_space=None)
727+
728+
result = extended.best_for(ufun)
729+
assert result is None
730+
731+
def test_best_for_with_singleton_outcome_space(self):
732+
"""Test best_for with a SingletonOutcomeSpace (single outcome in space)."""
733+
from negmas.outcomes.common import ExtendedOutcome
734+
from negmas.preferences import LinearUtilityFunction
735+
736+
issues = [make_issue(5, name="price"), make_issue(3, name="quantity")]
737+
ufun = LinearUtilityFunction.random(issues=issues)
738+
739+
single_outcome = (3, 1)
740+
singleton_os = SingletonOutcomeSpace(single_outcome, name="single")
741+
extended = ExtendedOutcome(outcome=None, outcome_space=singleton_os)
742+
743+
result = extended.best_for(ufun)
744+
assert result == single_outcome
745+
746+
def test_best_for_with_negative_weights(self):
747+
"""Test best_for correctly finds best with negative weights."""
748+
from negmas.outcomes.common import ExtendedOutcome
749+
from negmas.preferences import LinearUtilityFunction
750+
751+
issues = [make_issue(5, name="price"), make_issue(3, name="quantity")]
752+
os = make_os(issues)
753+
# Use negative weights so lower values are better
754+
ufun = LinearUtilityFunction(weights=[-1.0, -1.0], issues=issues)
755+
756+
extended = ExtendedOutcome(outcome=None, outcome_space=os)
757+
758+
result = extended.best_for(ufun)
759+
# With negative weights, best outcome should be (0, 0) - min values
760+
assert result == (0, 0)
761+
assert ufun(result) == ufun.max()
762+
763+
def test_best_for_with_mixed_weights(self):
764+
"""Test best_for correctly finds best with mixed positive/negative weights."""
765+
from negmas.outcomes.common import ExtendedOutcome
766+
from negmas.preferences import LinearUtilityFunction
767+
768+
issues = [make_issue(5, name="price"), make_issue(3, name="quantity")]
769+
os = make_os(issues)
770+
# First issue: higher is better, second issue: lower is better
771+
ufun = LinearUtilityFunction(weights=[1.0, -1.0], issues=issues)
772+
773+
extended = ExtendedOutcome(outcome=None, outcome_space=os)
774+
775+
result = extended.best_for(ufun)
776+
# Best should be (4, 0) - max first, min second
777+
assert result == (4, 0)
778+
assert ufun(result) == ufun.max()
779+
780+
def test_extract_outcome_with_extended_outcome(self):
781+
"""Test extract_outcome extracts the outcome from ExtendedOutcome."""
782+
from negmas.outcomes.common import ExtendedOutcome, extract_outcome
783+
784+
outcome = (5, 10)
785+
extended = ExtendedOutcome(outcome=outcome, data={"text": "test"})
786+
787+
assert extract_outcome(extended) == (5, 10)
788+
assert extract_outcome(outcome) == (5, 10)
789+
assert extract_outcome(None) is None
790+
791+
def test_extract_text_with_extended_outcome(self):
792+
"""Test extract_text extracts text from ExtendedOutcome data."""
793+
from negmas.outcomes.common import ExtendedOutcome, extract_text
794+
795+
extended_with_text = ExtendedOutcome(
796+
outcome=(5, 10), data={"text": "My proposal"}
797+
)
798+
extended_without_text = ExtendedOutcome(outcome=(5, 10), data={"other": "data"})
799+
extended_no_data = ExtendedOutcome(outcome=(5, 10))
800+
801+
assert extract_text(extended_with_text) == "My proposal"
802+
assert extract_text(extended_without_text) == ""
803+
assert extract_text(extended_no_data) is None
804+
assert extract_text((5, 10)) is None
805+
806+
def test_extended_outcome_is_frozen(self):
807+
"""Test that ExtendedOutcome is immutable (frozen)."""
808+
from negmas.outcomes.common import ExtendedOutcome
809+
810+
extended = ExtendedOutcome(outcome=(5, 10))
811+
812+
with pytest.raises(AttributeError):
813+
extended.outcome = (1, 2) # type: ignore
814+
815+
def test_best_for_with_subset_outcome_space(self):
816+
"""Test best_for with an outcome_space that's a subset of the ufun's space."""
817+
from negmas.outcomes.common import ExtendedOutcome
818+
from negmas.preferences import LinearUtilityFunction
819+
820+
# Create ufun with larger outcome space
821+
full_issues = [make_issue(10, name="price"), make_issue(5, name="quantity")]
822+
ufun = LinearUtilityFunction(weights=[1.0, 1.0], issues=full_issues)
823+
824+
# Create a smaller subset outcome space
825+
subset_issues = [make_issue(3, name="price"), make_issue(2, name="quantity")]
826+
subset_os = make_os(subset_issues)
827+
828+
extended = ExtendedOutcome(outcome=None, outcome_space=subset_os)
829+
830+
result = extended.best_for(ufun)
831+
# Best in subset should be (2, 1) - max values in the subset
832+
assert result == (2, 1)
833+
# Verify it's the best within the subset
834+
for o in subset_os.enumerate():
835+
assert ufun(result) >= ufun(o)

0 commit comments

Comments
 (0)