What I call "the new definition of fairness" seems like the provability version of what you call "niceness".
What a coincidence that we made these posts within two hours of each other.
I was thinking about how to make an analogue of the betrayal-based FairBot definition from the provability setting.
I think it requires being able to condition on the results of particular matches. We could do that in Python with a Judge object. The Judge runs matches, like this:
class EpsilonGroundedFairBot:
def __call__(self, opponent, judge):
if random.random() < ε:
return C
return judge.run(opponent, self)
judge.run checks some internal lookup table for a result for the match, and if it finds it, returns that result.
Otherwise it actually runs the bot.
In this case, we don't add to the lookup table.
We only add to the lookup table to condition on results of particular matches.
Using the conditioning idea to translate the idea of betrayal, I end up with something superficially like NiceProbeSimulator, but which I'll call ConditioningFairBot:
class ConditioningFairBot:
def __call__(self, opponent, judge):
newJudge = judge.condition(self, opponent, C)
return newJudge.run(opponent, self)
judge.condition(x, y, r) returns a new Judge, which will always say that x does r against y, just by checking its lookup table.
Seems to me like ConditioningFairBot should achieve efficient cooperation with another ConditioningFairBot.
After two recursions, we hit the lookup table.
This reads to me as a more elegant way of expressing what I was looking to do: it needs no source access, so there is no rewrite pass needed and nothing to obfuscate. I also think it will extend to strategies that do not simulate, resolving one of my blockers.
However, I do not think it addresses niceness without recognition. You learn what your opponent plays if it knows you will cooperate, but in real play it may not determine this, and act differently.
There are also issues with detecting that you are being simulated: a lookup will return much faster than a simulation. You may also need to enforce that strategies cannot run judge.run(opponent, fresh_copy(self)) or such.
Oh yes, interesting! And I do see the advantages of the betrayal framing.
How would you handle an opponent whose proving ability is simply too weak — one that often can't establish that anyone cooperates? It never defects on someone it knows cooperates, so it counts as fair in your sense, but only because it's never in a position to betray anyone in the first place. So I'd certify it and cooperate, and it would defect on me. Old-style fairness seems to fail safe here instead, since it asks whether the opponent actually cooperates rather than what they'd do if they knew.
In open-source game theory there are two standard approaches: simulate what the opponent will play against you (Robust program equilibrium, Oesterheld 2019), or prove it (Robust Cooperation in the Prisoner's Dilemma: Program Equilibrium via Provability Logic, Barasz et al. 2014).
Proving the behaviour of an arbitrary program is very challenging, and is certainly out of my skill set. Simulating the opponent is a more intuitive route to me, and a good example is EpsilonGroundedFairBot. For illustration purposes, I shall write strategies as callable Python classes.
This struggles with potentially having long run times, and with generalising to more than two players. Each call to EpsilonGroundedFairBot leads, on average, to (1-ε) further calls. Simulating n opponents makes that n(1-ε), which is bigger than 1 once n is 2 or more and ε is small, so the calls multiply instead of dying out. There are solutions, such as randomly selecting one opponent to simulate, but I wondered if we could do better.
I wanted to see if we can cut out nested simulation loops by instead searching for a property in the opponent. Let us call this property nice: a nice agent is one that will not attempt to exploit an opponent it believes will cooperate. Then I can try strategies of the form: if my opponent is nice, cooperate, otherwise defect.
The hope is that this gives a cooperative equilibrium. Such a strategy is itself nice — it only defects on agents that would have exploited it — so a population of them recognises each other and cooperates, while any agent that deviates towards exploitation gets identified and defected against. Nobody simulates anybody, and cooperation is still a best response. However, I cannot get this to work.
A first attempt: probing by simulation
An obvious try is to use simulation to see what the opponent will play against CooperateBot, a strategy that cooperates whatever it faces.
EpsilonGroundedFairBot is nice, because it can only defect if its opponent does, so it will surely cooperate against CooperateBot. NiceProbeSimulator(EpsilonGroundedFairBot) returns cooperation quickly, after two nested calls.
However, NiceProbeSimulator can be easily defeated by testing whether you are playing against a naive opponent — one that does not look at the strategy of its opponent at all — and behaving differently against them:
The general lesson is that any test run against a naive strategy can be spotted. The opponent can see who it is playing, so it can always tell the test apart from the real game and answer them differently. Patching this particular trick will not help.
A second attempt: probing by inspection
My second, more serious attempt is to achieve this via inspection. If allowed to view the opponent's source code, a program could edit it as follows: replace any call to me with the value C. Analysis of the edited code then determines whether it can still return D, and cooperates if it cannot.
The intuition is that a nice agent only defects because a simulation came back D. Replace the simulations with C and it has no route to D left.
This removes the nested simulations, because I only analyse a modified version of the opponent which has had its simulations taken out. After modification, EpsilonGroundedFairBot returns C on both branches, and the analyser trivially identifies that every reachable return is C. This is still proving, but in the easy case: what makes proof hard is the fixed point running through the opponent's reasoning about me, and the edit deletes it, leaving a program that no longer calls back into me at all.
If we are not careful, though, this can be exploited by using the fact that all simulated calls now come back C:
Here "opponent(DefectBot())" is replaced with C, which enters a logical path that is otherwise never taken, and the agent is wrongly certified. The fix is to sharpen the definition of niceness: a nice program cooperates against a program it believes will cooperate against it. So the only calls that matter are the ones about me, in both directions. I replace only calls of the form "opponent(self)", leaving "opponent(DefectBot())" alone and unresolvable, so AntiNiceProbeInspector is correctly judged not nice. And I bind the opponent's argument to my own source, so a comparison such as "isinstance(opponent, DefectBot)" resolves to false and the branch disappears.
Can we make these edits? Restrictions on how strategies may interact are needed to keep the rule meaningful. We would need to forbid aliasing, copying and rebinding of the opponent, since otherwise the replacement can be obfuscated away with a call such as "dummy = opponent; dummy(self)".
Two problems that remain
Suppose all of that could be made to work with some implementation. Two problems remain, and I do not think either is a bug that a further patch removes.
First: strategies that never simulate
What about strategies that do not simulate their opponents at all? The mirror, NiceProbeInspector(NiceProbeInspector), fails to produce C,C. It never calls "opponent(self)", so the replacement changes nothing, the analyser cannot resolve what is left, and we get D,D.
Fixing this means recognising when a strategy inspects what its opponent does against it, and substituting in the belief that the opponent cooperates. That raises a further question: if a strategy modifies its opponent and then simulates or inspects this object, should those calls be replaced too? NiceProbeInspector is exactly such a strategy, so answering "no" leaves the mirror broken. But modifications in general cannot be replaced with C, or we open the door to a variant of AntiNiceProbeInspector that rewrites the opponent's code to "return D" and inspects that.
Second: niceness without recognition
Niceness has a failure mode of its own: an agent can be prepared to cooperate against nice agents, but fail to identify them. There can be a gap between what my opponent does assuming that I will cooperate, and what it will do when it actually plays against me, even though I judge it to be nice and cooperate.
NiceProbeInspector(BudgetBot) correctly judges BudgetBot to be nice, and cooperates. The replacement leaves nothing expensive behind, so the timeout cannot fire and the analyser certifies it. However, in genuine play, if simulating NiceProbeInspector in BudgetBot(NiceProbeInspector) takes too long, BudgetBot will defect.
N.B. This is not specific to inspection. BudgetBot will time out against EpsilonGroundedFairBot as readily as against NiceProbeInspector: any agent that must reach a conclusion about its opponent under a resource bound can fail to reach it, whatever method it uses.
Conclusion
These two problems point in opposite directions. The first is my recogniser failing to certify an agent I would want to cooperate with; the second is an agent I have correctly certified failing to certify me.
What I would want to know next is whether there is a property that rules out both exploitation and failure-to-recognise, and whether it can be tested without going back to simulation.