123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180(* Claude Code
*
* Copyright (C) 2026 Yoann Padioleau
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Library General Public License
* (LGPL) as published by the Free Software Foundation; either version
* 2 of the License, or (at your option) any later version.
*)(* See Mcts.mli *)type'moveresult={best:'moveoption;tried:('move*int*float)list;playouts:int;nodes:int}(* a node of the tree: the move that led to it, what the playouts
* through it came to (always counted for MAX: [wins] is MAX's share),
* the children tried so far, and the moves not tried yet *)type'movenode={move:'moveoption;mutablevisits:int;mutablewins:float;(* MAX's, between 0 and [visits] *)mutablechildren:'movenodelist;mutableuntried:'movelist;prior:float;(* what a policy thought of this move, 1 with none *)}letnode?(prior=1.)(move:'moveoption)(moves:'movelist):'movenode={move;visits=0;wins=0.;children=[];untried=moves;prior}(* a finished game, as MAX's share: 1 won, 0 lost, 1/2 drawn *)letoutcome(game:('state,'move)Minimax.game)(state:'state):float=lets=game.scorestateinifs>0.then1.elseifs<0.then0.else0.5(* the default playout: uniformly random moves to the end *)letrandom_playout(st:Lehmer.state)(game:('state,'move)Minimax.game)(state:'state):'state=letrecgostate=matchgame.movesstatewith|[]->state|moves->go(game.playstate(List.nthmoves(Lehmer.intst(List.lengthmoves))))ingostate(* UCB1, from the point of view of whoever is to play at [parent]: what
* has been working for *them*, plus what has barely been tried.
*
* With a policy ([policy = true]) this is PUCT instead: the second
* term is weighted by what the policy thought of the move, and an
* unvisited child is no longer infinitely attractive but as
* attractive as the policy says *)letucb~(policy:bool)(exploration:float)(maximizing:bool)(parent_visits:int)(c:'movenode):float=ifpolicythenletshare=ifc.visits=0then0.5elsec.wins/.float_of_intc.visitsinletmine=ifmaximizingthenshareelse1.-.shareinmine+.(exploration*.c.prior*.sqrt(float_of_intparent_visits)/.(1.+.float_of_intc.visits))elseifc.visits=0thenFloat.infinityelseletshare=c.wins/.float_of_intc.visitsinletmine=ifmaximizingthenshareelse1.-.shareinmine+.(exploration*.sqrt(log(float_of_intparent_visits)/.float_of_intc.visits))type('state,'move)thinking={game:('state,'move)Minimax.game;root_state:'state;root:'movenode;exploration:float;playout:Lehmer.state->('state,'move)Minimax.game->'state->'state;(* AlphaGo's two: what the moves are worth before trying them, and
* what a position is worth without playing it out *)prior:('state->('move*float)list)option;evaluate:('state->float)option;st:Lehmer.state;mutableplayed:int;mutablenodes:int;}letstart?exploration?(seed=0)?(playout=random_playout)?prior?evaluate(game:('state,'move)Minimax.game)(state:'state):('state,'move)thinking={game;root_state=state;(* PUCT wants a larger constant than UCB1: its exploring term
* falls off as 1/(1+N) rather than sqrt(log N / N) *)exploration=(matchexplorationwithSomee->e|None->ifprior=Nonethensqrt2.else1.5);root=nodeNone(game.movesstate);playout;prior;evaluate;st=Lehmer.makeseed;played=0;nodes=1;}(* one iteration: select, expand, simulate, backup. The path taken is
* returned so the result can be added to every node on it *)letrecdescend(t:('state,'move)thinking)(n:'movenode)(state:'state)(path:'movenodelist):'movenodelist*'state=ifn.untried<>[]then((* expand: one of the moves never tried here *)leti=Lehmer.intt.st(List.lengthn.untried)inletmove=List.nthn.untriediinn.untried<-List.filteri(funj_->j<>i)n.untried;letchild_state=t.game.playstatemoveinletprior=matcht.priorwith|None->1.|Somepolicy->(matchList.assoc_optmove(policystate)withSomep->p|None->0.01)inletchild=node~prior(Somemove)(t.game.moveschild_state)inn.children<-child::n.children;t.nodes<-t.nodes+1;(child::path,child_state))elsematchn.childrenwith(* the game is over here: nothing to expand, nothing to choose *)|[]->(path,state)|children->letmaximizing=t.game.max_to_playstateinletpolicy=t.prior<>Noneinletscorec=ucb~policyt.explorationmaximizingn.visitscinletbest=List.fold_left(funbestc->ifscorec>scorebestthencelsebest)(List.hdchildren)childrenindescendtbest(t.game.playstate(Option.getbest.move))(best::path)letiterate(t:('state,'move)thinking):unit=let(path,state)=descendtt.roott.root_state[t.root]inletshare=matcht.evaluatewith(* a value head replaces the playout: one opinion instead of one
* random game -- except at a position that is already over, where
* the rules know better than any network *)|Somevaluewhent.game.movesstate<>[]->Float.max0.(Float.min1.(valuestate))|_->outcomet.game(t.playoutt.stt.gamestate)inList.iter(funn->n.visits<-n.visits+1;n.wins<-n.wins+.share)path;t.played<-t.played+1letplan(t:('state,'move)thinking):'moveresult=lettried=List.map(fun(c:'movenode)->(Option.getc.move,c.visits,ifc.visits=0then0.elsec.wins/.float_of_intc.visits))t.root.childrenin(* MCTS's answer is the most visited child, not the best scoring one:
* a high score on three playouts means nothing, and the selection
* rule has already spent its visits on what it believes.
*
* Ties go to the better share, from the point of view of whoever is
* to play. Early on every child has been visited once and the
* visits decide nothing at all -- without this the answer would be
* whichever move happened to be first in the list, which is how a
* search with a *perfect* evaluation was seen to give a losing move
* at twelve playouts (Unit_mcts). *)letmaximizing=t.game.max_to_playt.root_stateinletmineshare=ifmaximizingthenshareelse1.-.shareinletbest=List.fold_left(funbest(m,visits,share)->matchbestwith|Some(_,v,s)whenv>visits||(v=visits&&mines>=mineshare)->best|_->Some(m,visits,share))Nonetriedinletbest=Option.map(fun(m,v,_)->(m,v))bestin{best=Option.mapfstbest;tried;playouts=t.played;nodes=t.nodes}letthink~(playouts:int)(t:('state,'move)thinking):('state,'move)thinking=for_=1toplayoutsdoiteratetdone;tletsearch?exploration?seed?playout?prior?evaluate(game:('state,'move)Minimax.game)~(playouts:int)(state:'state):'moveresult=plan(think~playouts(start?exploration?seed?playout?prior?evaluategamestate))