Skip to content

Commit d96f877

Browse files
tturocyrahulsavani
andauthored
New syntax for node children and paths (#587) (#588)
This changes the indexing operator on `Node.children` to search the labels of actions available at the node, rather than the labels of the children nodes themselves. It also extends the operator to accept an action to index by, the interpretation of which is to return the node which succeeds the parent node following that action. Game-building tutorials in the user guide have been updated to use this new feature. Closes #587. --------- Co-authored-by: Rahul Savani <rahul.savani@gmail.com>
1 parent 9995912 commit d96f877

File tree

6 files changed

+91
-46
lines changed

6 files changed

+91
-46
lines changed

ChangeLog

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
### Changed
66
- In the graphical interface, removed option to configure information set link drawing; information sets
77
are always drawn and indicators are always drawn if an information set spans multiple levels.
8+
- In `pygambit`, indexing the children of a node by a string inteprets the string as an action label,
9+
not a label of a child node. In addition, indexing by an action object is now supported. (#587)
810

911
### Added
1012
- Tests for EFG Nash solvers -- `enumpoly_solve`, `lp_solve`, `lcp_solve` -- in behavior stratgegies

doc/pygambit.user.rst

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ We can then also add the Seller's move in the situation after the Buyer chooses
4848

4949
.. ipython:: python
5050
51-
g.append_move(g.root.children[0], "Seller", ["Honor", "Abuse"])
51+
g.append_move(g.root.children["Trust"], "Seller", ["Honor", "Abuse"])
5252
5353
Now that we have the moves of the game defined, we add payoffs. Payoffs are associated with
5454
an :py:class:`.Outcome`; each :py:class:`Outcome` has a vector of payoffs, one for each player,
@@ -57,20 +57,20 @@ Seller proving themselves trustworthy:
5757

5858
.. ipython:: python
5959
60-
g.set_outcome(g.root.children[0].children[0], g.add_outcome([1, 1], label="Trustworthy"))
60+
g.set_outcome(g.root.children["Trust"].children["Honor"], g.add_outcome([1, 1], label="Trustworthy"))
6161
6262
Next, the outcome associated with the scenario where the Buyer trusts but the Seller does
6363
not return the trust:
6464

6565
.. ipython:: python
6666
67-
g.set_outcome(g.root.children[0].children[1], g.add_outcome([-1, 2], label="Untrustworthy"))
67+
g.set_outcome(g.root.children["Trust"].children["Abuse"], g.add_outcome([-1, 2], label="Untrustworthy"))
6868
6969
And, finally the outcome associated with the Buyer opting out of the interaction:
7070

7171
.. ipython:: python
7272
73-
g.set_outcome(g.root.children[1], g.add_outcome([0, 0], label="Opt-out"))
73+
g.set_outcome(g.root.children["Not trust"], g.add_outcome([0, 0], label="Opt-out"))
7474
7575
Nodes without an outcome attached are assumed to have payoffs of zero for all players.
7676
Therefore, adding the outcome to this latter terminal node is not strictly necessary in Gambit,
@@ -111,19 +111,19 @@ We can build this game using the following script::
111111
g.append_move(g.root, g.players.chance, ["King", "Queen"])
112112
for node in g.root.children:
113113
g.append_move(node, "Alice", ["Raise", "Fold"])
114-
g.append_move(g.root.children[0].children[0], "Bob", ["Meet", "Pass"])
115-
g.append_infoset(g.root.children[1].children[0],
116-
g.root.children[0].children[0].infoset)
114+
g.append_move(g.root.children["King"].children["Raise"], "Bob", ["Meet", "Pass"])
115+
g.append_infoset(g.root.children["Queen"].children["Raise"],
116+
g.root.children["King"].children["Raise"].infoset)
117117
alice_winsbig = g.add_outcome([2, -2], label="Alice wins big")
118118
alice_wins = g.add_outcome([1, -1], label="Alice wins")
119119
bob_winsbig = g.add_outcome([-2, 2], label="Bob wins big")
120120
bob_wins = g.add_outcome([-1, 1], label="Bob wins")
121-
g.set_outcome(g.root.children[0].children[0].children[0], alice_winsbig)
122-
g.set_outcome(g.root.children[0].children[0].children[1], alice_wins)
123-
g.set_outcome(g.root.children[0].children[1], bob_wins)
124-
g.set_outcome(g.root.children[1].children[0].children[0], bob_winsbig)
125-
g.set_outcome(g.root.children[1].children[0].children[1], alice_wins)
126-
g.set_outcome(g.root.children[1].children[1], bob_wins)
121+
g.set_outcome(g.root.children["King"].children["Raise"].children["Meet"], alice_winsbig)
122+
g.set_outcome(g.root.children["King"].children["Raise"].children["Pass"], alice_wins)
123+
g.set_outcome(g.root.children["King"].children["Fold"], bob_wins)
124+
g.set_outcome(g.root.children["Queen"].children["Raise"].children["Meet"], bob_winsbig)
125+
g.set_outcome(g.root.children["Queen"].children["Raise"].children["Pass"], alice_wins)
126+
g.set_outcome(g.root.children["Queen"].children["Fold"], bob_wins)
127127

128128
All extensive games have a chance (or nature) player, accessible as
129129
``.Game.players.chance``. Moves belonging to the chance player can be added in the same
@@ -140,9 +140,9 @@ causes each of the newly-appended moves to be in new information sets. In contr
140140
does not know Alice's card, and therefore cannot distinguish between the two nodes at which
141141
he has the decision. This is implemented in the following lines::
142142

143-
g.append_move(g.root.children[0].children[0], "Bob", ["Meet", "Pass"])
144-
g.append_infoset(g.root.children[1].children[0],
145-
g.root.children[0].children[0].infoset)
143+
g.append_move(g.root.children["King"].children["Raise"], "Bob", ["Meet", "Pass"])
144+
g.append_infoset(g.root.children["Queen"].children["Raise"],
145+
g.root.children["King"].children["Raise"].infoset)
146146

147147
The call :py:meth:`.Game.append_infoset` adds a move at a terminal node as part of
148148
an existing information set (represented in ``pygambit`` as an :py:class:`.Infoset`).

src/games/game.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ class UndefinedException final : public std::runtime_error {
8282
class MismatchException : public std::runtime_error {
8383
public:
8484
MismatchException() : std::runtime_error("Operation between objects in different games") {}
85+
explicit MismatchException(const std::string &s) : std::runtime_error(s) {}
8586
~MismatchException() noexcept override = default;
8687
};
8788

@@ -444,7 +445,7 @@ class GameNodeRep : public std::enable_shared_from_this<GameNodeRep> {
444445
GameNode GetChild(const GameAction &p_action)
445446
{
446447
if (p_action->GetInfoset().get() != m_infoset) {
447-
throw MismatchException();
448+
throw MismatchException("Action is from a different information set than node");
448449
}
449450
return m_children.at(p_action->GetNumber() - 1);
450451
}

src/pygambit/node.pxi

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,23 +45,37 @@ class NodeChildren:
4545
for child in self.parent.deref().GetChildren():
4646
yield Node.wrap(child)
4747

48-
def __getitem__(self, index: typing.Union[int, str]) -> Node:
49-
if isinstance(index, str):
50-
if not index.strip():
51-
raise ValueError("Node label cannot be empty or all whitespace")
52-
matches = [x for x in self if x.label == index.strip()]
53-
if not matches:
54-
raise KeyError(f"Node has no child with label '{index}'")
55-
if len(matches) > 1:
56-
raise ValueError(f"Node has multiple children with label '{index}'")
57-
return matches[0]
58-
if isinstance(index, int):
48+
def __getitem__(self, action: typing.Union[int, str, Action]) -> Node:
49+
"""Returns the successor node which is reached after 'action' is played.
50+
51+
.. versionchanged: 16.5.0
52+
Previously indexing by string searched the labels of the child nodes,
53+
rather than referring to actions. This implements the more natural
54+
interpretation that strings refer to action labels.
55+
56+
Relatedly, the collection can now be indexed by an Action object.
57+
"""
58+
if isinstance(action, str):
59+
if not action.strip():
60+
raise ValueError("Action label cannot be empty or all whitespace")
61+
if self.parent.deref().GetInfoset() == cython.cast(c_GameInfoset, NULL):
62+
raise ValueError(f"No action with label '{action}' at node")
63+
for act in self.parent.deref().GetInfoset().deref().GetActions():
64+
if act.deref().GetLabel().decode("ascii") == cython.cast(str, action):
65+
return Node.wrap(self.parent.deref().GetChild(act))
66+
raise ValueError(f"No action with label '{action}' at node")
67+
if isinstance(action, Action):
68+
try:
69+
return Node.wrap(self.parent.deref().GetChild(cython.cast(Action, action).action))
70+
except IndexError:
71+
raise ValueError(f"Action is from a different information set than node")
72+
if isinstance(action, int):
5973
if self.parent.deref().GetInfoset() == cython.cast(c_GameInfoset, NULL):
6074
raise IndexError("Index out of range")
6175
return Node.wrap(self.parent.deref().GetChild(
62-
self.parent.deref().GetInfoset().deref().GetAction(index + 1)
76+
self.parent.deref().GetInfoset().deref().GetAction(action + 1)
6377
))
64-
raise TypeError(f"Child index must be int or str, not {index.__class__.__name__}")
78+
raise TypeError(f"Index must be int, str, or Action, not {action.__class__.__name__}")
6579

6680

6781
@cython.cclass

tests/games.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -146,28 +146,33 @@ def create_kuhn_poker_efg() -> gbt.Game:
146146
g = gbt.Game.new_tree(
147147
players=["Alice", "Bob"], title="Three-card poker (J, Q, K), two-player"
148148
)
149+
cards = ["J", "Q", "K"]
149150
deals = ["JQ", "JK", "QJ", "QK", "KJ", "KQ"]
151+
152+
def deals_by_infoset(player, card):
153+
player_idx = 0 if player == "Alice" else 1
154+
return [d for d in deals if d[player_idx] == card]
155+
150156
g.append_move(g.root, g.players.chance, deals)
151157
g.set_chance_probs(g.root.infoset, [gbt.Rational(1, 6)]*6)
152-
# group the children of the root (indices of `deals`) by each player's dealt card
153-
alice_grouping = [[0, 1], [2, 3], [4, 5]] # J, Q, K
154-
bob_grouping = [[0, 5], [1, 3], [2, 4]] # Q, K, J
155-
156-
# Alice's first move
157-
for ij in alice_grouping:
158-
term_nodes = [g.root.children[k] for k in ij]
158+
for alice_card in cards:
159+
# Alice's first move
160+
term_nodes = [g.root.children[d] for d in deals_by_infoset("Alice", alice_card)]
159161
g.append_move(term_nodes, "Alice", ["Check", "Bet"])
160-
# Bob's move after Alice checks
161-
for ij in bob_grouping:
162-
term_nodes = [g.root.children[k].children[0] for k in ij]
162+
for bob_card in cards:
163+
# Bob's move after Alice checks
164+
term_nodes = [g.root.children[d].children["Check"]
165+
for d in deals_by_infoset("Bob", bob_card)]
163166
g.append_move(term_nodes, "Bob", ["Check", "Bet"])
164-
# Alice's move if Bob's second action is bet
165-
for ij in alice_grouping:
166-
term_nodes = [g.root.children[k].children[0].children[1] for k in ij]
167+
for alice_card in cards:
168+
# Alice's move if Bob's second action is bet
169+
term_nodes = [g.root.children[d].children["Check"].children["Bet"]
170+
for d in deals_by_infoset("Alice", alice_card)]
167171
g.append_move(term_nodes, "Alice", ["Fold", "Call"])
168-
# Bob's move after Alice bets initially
169-
for ij in bob_grouping:
170-
term_nodes = [g.root.children[k].children[1] for k in ij]
172+
for bob_card in cards:
173+
# Bob's move after Alice bets initially
174+
term_nodes = [g.root.children[d].children["Bet"]
175+
for d in deals_by_infoset("Bob", bob_card)]
171176
g.append_move(term_nodes, "Bob", ["Fold", "Call"])
172177

173178
def calculate_payoffs(term_node):

tests/test_node.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,29 @@ def test_node_plays():
790790
assert set(test_node.plays) == expected_set_of_plays
791791

792792

793+
def test_node_children_action_label():
794+
game = games.read_from_file("poker.efg")
795+
assert game.root.children["Red"] == game.root.children[0]
796+
assert game.root.children["Black"].children["Fold"] == game.root.children[1].children[1]
797+
798+
799+
def test_node_children_action():
800+
game = games.read_from_file("poker.efg")
801+
assert game.root.children[game.root.infoset.actions["Red"]] == game.root.children[0]
802+
803+
804+
def test_node_children_nonexistent_action():
805+
game = games.read_from_file("poker.efg")
806+
with pytest.raises(ValueError):
807+
_ = game.root.children["Green"]
808+
809+
810+
def test_node_children_other_infoset_action():
811+
game = games.read_from_file("poker.efg")
812+
with pytest.raises(ValueError):
813+
_ = game.root.children[game.root.children[0].infoset.actions["Raise"]]
814+
815+
793816
@pytest.mark.parametrize(
794817
"game_obj",
795818
[

0 commit comments

Comments
 (0)