From ce8c2128b485aef6287688dc6ad755d690a6f1e7 Mon Sep 17 00:00:00 2001 From: yuyr Date: Fri, 26 Dec 2025 10:50:33 +0800 Subject: [PATCH] =?UTF-8?q?v2.0=20=E8=A1=A5=E5=85=85=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=8C=E8=A1=8C=E8=A6=86=E7=9B=9690?= =?UTF-8?q?=E4=BB=A5=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + pytest.ini | 3 + specs/mvp/image/roadmap_v2.5.png | Bin 0 -> 82052 bytes specs/mvp/mvp_roadmap_v2.md | 185 ++--- specs/mvp/sw_arch.excalidraw | 833 ++++++++++++++++----- specs/mvp/v2.5/README.md | 14 + specs/mvp/v2.5/v2.5_acceptance.md | 67 ++ specs/mvp/v2.5/v2.5_api.md | 109 +++ specs/mvp/v2.5/v2.5_design.md | 255 +++++++ src/mvp/py/requirements-dev.txt | 4 + src/mvp/py/tests/conftest.py | 77 ++ src/mvp/py/tests/test_app.py | 166 ++++ src/mvp/py/tests/test_builders.py | 53 ++ src/mvp/py/tests/test_cli_run.py | 77 ++ src/mvp/py/tests/test_db.py | 42 ++ src/mvp/py/tests/test_driver_entrypoint.py | 25 + src/mvp/py/tests/test_ids.py | 28 + src/mvp/py/tests/test_models.py | 107 +++ src/mvp/py/tests/test_ray_job_tool.py | 162 ++++ src/mvp/py/tests/test_ray_resources.py | 25 + src/mvp/py/tests/test_scheduler.py | 203 +++++ src/mvp/py/tests/test_server.py | 38 + src/mvp/py/tests/test_service_config.py | 40 + src/mvp/py/tests/test_yaml_io.py | 34 + 24 files changed, 2265 insertions(+), 286 deletions(-) create mode 100644 pytest.ini create mode 100644 specs/mvp/image/roadmap_v2.5.png create mode 100644 specs/mvp/v2.5/README.md create mode 100644 specs/mvp/v2.5/v2.5_acceptance.md create mode 100644 specs/mvp/v2.5/v2.5_api.md create mode 100644 specs/mvp/v2.5/v2.5_design.md create mode 100644 src/mvp/py/requirements-dev.txt create mode 100644 src/mvp/py/tests/conftest.py create mode 100644 src/mvp/py/tests/test_app.py create mode 100644 src/mvp/py/tests/test_builders.py create mode 100644 src/mvp/py/tests/test_cli_run.py create mode 100644 src/mvp/py/tests/test_db.py create mode 100644 src/mvp/py/tests/test_driver_entrypoint.py create mode 100644 src/mvp/py/tests/test_ids.py create mode 100644 src/mvp/py/tests/test_models.py create mode 100644 src/mvp/py/tests/test_ray_job_tool.py create mode 100644 src/mvp/py/tests/test_ray_resources.py create mode 100644 src/mvp/py/tests/test_scheduler.py create mode 100644 src/mvp/py/tests/test_server.py create mode 100644 src/mvp/py/tests/test_service_config.py create mode 100644 src/mvp/py/tests/test_yaml_io.py diff --git a/.gitignore b/.gitignore index ce0d616..a7f185a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ verl/ skypilot-ssh-test/ ray_in_docker/ __pycache__/ +.venv/ +.pytest_cache/ +.coverage +htmlcov/ diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..49e2155 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = src/mvp/py/tests +addopts = --maxfail=1 --cov=argus --cov=server --cov-report=term-missing --cov-fail-under=90 diff --git a/specs/mvp/image/roadmap_v2.5.png b/specs/mvp/image/roadmap_v2.5.png new file mode 100644 index 0000000000000000000000000000000000000000..622503981462193b5fb4fb539a05a02fd32603e4 GIT binary patch literal 82052 zcmeAS@N?(olHy`uVBq!ia0y~yVA;gLz_@~giGhLPE_d`71_lPs0*}aI1_q%L5N5oW zCSSq8z#v%S8c`CQpH@qPN(i9 zcj1XgCLRBjq0>1-!$~Ol{d!ZwZBGK{`sd*v*ub_SD$}##_0PT{oG+qMXRSqtKvqd35l4I4;ljXhCEJ6W5iIt~bV)0i!SGdT)VKi- zyNw%}L2c%9Wboo$5lJC9B_`T2Z7{ zTRqeA>GZHqViP@%Y|8QT^D8SWi;B9{%+9~V;&_jwu!u-Xa&q#KTGn5XlpUbFE${BG zq@!H#mhRkHd3{~1t*!0eJ$tOm-{mAGCLXzw<>%?yIgc~_r>j)&vOhJ+`4?_q*qoX+ zbBUk%-9?v$I*;6#og^{UtMuuqsS6e?xN|4Q$z!sbuUXEG3ya%+C5Du2<6d*|W2=TLiedyMveeSz235TPv;LVOzn_ z@O|Nq9Y0=PUcQ5$HTA)P1}o3iSI^C{3|<<<$AXhPfgX9wJs|u zE4#O+()h5!F}~$&GiT0RdG*zvJvPbgLFsSr@2@{I!*Jokg~#}g7+4rAc$X-V_Vm=$ zmI?j#|0>?x*m#VO{lJWUzh13A)+>Ga<^8(fXLlTQc={?ZYDLh>OP4O`#q5|cW5$aA zU(56I^0Km4xh=jp_vfFVpHG}P5mg>8w`#82;>y3jzD94aum1TdH6bCPuyAA8>bpA% zlWS^fzP7m=@&t#3e0leJ;_0XFFNy!$oPM4|(SM#z<#&$-Hq%mtg@rj4%irHKEz@Z` z*vMGw@8RL$=GL}z=g(VPvwLN^8zy?H2-VluTb=#t!2Bmf|I(o+Po8iJtL@me>)VTq z$(NRRHnZ`*y0!InueAA>%Et>Fo2$OPdHLwkqZco3Y|XyDE_Sz-mDRVmx69w$*jW7h z+}hh$tpk?Y|NC+H?p<3et6y*L{ktAtpPG{L=gVb(?`b-T63g1!Ecy8O-re4=pL_1h zwW6XTHhwvq>}zYbpW}IXQc0})zA8_JaI4eAdGq}K-8{dQgO6uLwUd(*hoZ2W&w+@0 z3@sBBo!c&S9y;XI>XdjVVS!Moy{=91voi}Eo7ctcR1)j1{`ST)>xzc8_3k};_N)wE z{)%r$#AqK(On4_f(Tt+r%bZ1PJ_Ns%y@Sm7djKC7#%Yg^vkQwBcY-`(AwcQ-3J*_k6y zYpVU94~OU1|C5xHySFjPv5w4!*jCQ#W@oMl%{&M^GJSrKEHln z)z?*%z8mofOGj{^y=#Bq>Vq$&9#nWMP#Z2^C zYG`PvHTBfNgNbi$Y~+r8UfsPtmHBtoEY;Q3 z)j(ib>FaBU+xeF#v!te{zrVHh_0P}G#r0woT3T9KE*y{5SQ)Y^J?h8z`}Oknb$?Dy zR{wHw>(1imMcqe|3b*Io-LrS^*PdWd&BSINv%cVU$-^&-R{Jw^Z*4KmQ^|k$mXC*_ zZdd7RF~ud(+w+d`u^c$DY15`}Z*QCD-Pz&RD|K(=et`(w|zy1Lf?`}zFC z!^1nHAFo`wl3Pq?L9XA5o}QkotHY;PE)H60Q~mAD%HZWLN*7;TT>SgnTkpw@EKPfgW+dwYBQmW+uTZ%q;v6U#D@l9rbKyZSWGqKg^p;`Ubk z`SG!-sVQ)=TkW5Z$AeaGSr&fOz{kf5((hZh;UhR4O-`}6a2X?5qD3k#j!-`o59%gf6nT-^%O!<>h;1t;_y=JTBkPFQ0d3$H#|<+rPiR@6W->$M^2?a{sCC zeLXxZDn2}zVVHbtd;a;eI(mA2U0qoh7qON*Z}Cdy(~5OBao`^YgQ7x0s;QxjB}` z$;Wuw5|1Pm{{Q#a*48%FXy)wM(KAE$R(*YC|Nkeyd6lD!i%USjg)1wAS(zHWmYzCs zB4G8^^7r@VS{AFVJzx3p(a{D*=0AV_X!TE+boy!1#u;%l#6(15_SNjnzrQba7He91 zdj72~pemW?u*XtRQS<8Et`jFt+}&NSAHQ!;;p1b9ii+IgdMYhB_xIIaSm3xzJ5l1< zfkx)v-`*~+oyH?=_U8Bd{raC-HK(4sySqI9_O`cQUtbqi^ZD`b_xsZ!yrQC_XJ?!1 z+t|$6x2Njss{Q-_Us~#YIzGdQCpai5DKpcP<6?%1xPDyD#YL{|e6n9+rpD-jlJf4| zzs2Kg4!-?=Z*O(}?QLi8+_`i3@M70)F;``Syt}I_Y<*npvz`YbMGK60 z76)jYIdi7Oxc$`W@O`B&?=LTRTDfxN&!0atGc&cr)>zd3GMT%x{QW(C`#%9n6GQ3_ z%(p7dy1p*f%*g+5mFE1}Dy0tZXdf4igy}t*P1#yU%r2zeln$7TwhO9)6>iA)$Q%?8yK03ii;zs>FVi$%BKGQ{`Kqg zm;24ti{E#~fak5;=1Dio-ruYJ`YLoGh_1P!?hRo|Nr}b(j=jEADY?u z_w3!9Y81JGq&t$lWC$@1m=@^&%H9{>6I`S$jFdHXt>*r`!# z`FMDyoJ{%u^L+iM&!3BnivIlhV^#j{&y$nFyV`#I_z_?KH*{^-^CwTfyuE$>Wip$IE|NJo2jeNRl)8@_9UtgJ4eR*-_%o+Xtegf2C`yV@g ze0%QgvaheMPJQ#1tB~xSr1JFBnc=HL-|?;uv(CPzbJw{w_2!#5Z|eU3I+?P`H2Yde(W}tP0NaWW3(DW$ zD|zFMoKKmFq3;#8xX({!VE7=_9` znQBq>MWcLMRb}PH{vU6*-+ys!<<(bDPfx!*^WMFC^?yDdKiJHk$*{|Q?XMiapUlh) z!lJikP4!WmZJIr8BQvAHzD0|aeyM{RN&EKgD~ZyZe*F0H%P*HKQAs`ys;2AaEY7~Z zZg2JXx_^Hv6B8B9jg3HHQ~CRQ^0i+AXMWN--PYDt_wVQP%F4>d#>U6T``7Q8`uNSw z&5s{}z@*8O4<9=8=g*&CUtfR!{eFM>>uYb{-QB&#e3qApxVX58$d>HueXF%Xw5GQ6 z%d7o2i8D1dooiLPYUc;Q8bNslwicqJ^qEqI&dV24!$Jd`ddv+=R?7cNV zjdXQ&Ra9EGt?ljVT4cI$%aZx?i;GS5H=os4@9nR**VD^8+{W87Vb7jDtHak{ zniRUOON}#Xt(l67ijh&#hX)4_9z4j@_~1}0_tX8$PW{$fb>Zx6bADN?EBk7H7d}3= z^6D#)hqhnfQ2hAu@3q{2{k39N1M;v&9<%HHgo1o2L*vnm(X{-txgkX&RiM2`>4U0 zUteEOo-}FBoH_e`JnHuH+pERD>h<;Y%a<&Ph>UD)X;~A!J?}_|prfOsp`qdZy5G4q zHGlZ+|2WLPucD^L#>$$x^;YHQXRfZUclXu)es*^DmMo|3kKb$Ye=-l$5OMF5F*G!^ zEO`-7Qu5{P?d| zURugs-pXSW*nQb)Wyr6C?D8pTX_sbYIGtD>zCLVCgyKYxeZSvXuL~4AY5*!qjMC4^ zv?bR4`RINtVC|=`+$>DdU~qTRv}x1g>wc>KOn=kmb>ZKm+gmaZe=RHi{Vn&FA?Nkb zg!OTI4;?yWV`CE(6!hos-*!IPs>jE8@4mYgzpuvgc6{#LU8S$CtSo+Z=I4*c{qcJ$ z9zK8W9Fjb`RtSm0Q+b57pepp|#F zWCj-&e*E?IH9N1A%XjCPm^nc!Yreb)oVg_RjG&cg&YfG< zedy4kb@BV(y?-w+BI2SS7aSZcZ(p}2Z1v8@2cQP*-(RKL+N=Nn`@Vl~^>?xEqq(iUIgzjCvIy)uh$voTY7i(wT-Ch3s*ROSvn?dcSscT$x#LmyN{XIRt zZsX?7*45wEY~TL<`~CX(eZHknd46?i2R?1g%BZZ}w(ivBfA#lwPoF%wU|CsN`Q;|>mush;w_50`v|Ko~@xzCLTSY~WkM(+bdTvfX zulM;uyr18>IhMuO*2QXza9v*)YhC&2N%_x{*NQFjH%91)aEWrUuJt*6_H68)3d6O+ z7xpfG|L9TCn;RRs#r1k*EI&Ow-2U(1zh*YxN2~1QY$_&9ocQsq`TZVQ>u;Z)o?f_c z;j?GYw8PhZ`TqTSEVJT~3-iJkEn4*K{QUbXgVn>suTPpZ>Fw?9)>c;UZf;ILGsCb& zz>4A4=JfN67A-o~BWYamA>r4$D_5?}wJsNO^78V^_&=>h;Fdh+=}nD|jayQFe|yWy z#JKlau!hLPhY$Vy{EDBQS$O?E&*3*WHa1`TIM23v+lmz)Yr`(TYW19~_Oh=0;>C;W z2v(P8cAvC+q+6%=iS|P^X5(6hz$-JBI_bHKAIk1 z7rCe6V-qX4iqOerzO$d5ogMyjx`qG;C?iB~&-?rD_j_5Z5{sfI9_!=w-U})A*dOfF z5wvoSRq3lsOTEAT@3PF=I&1aA%3ohH&(E`6yl7EJ9s4T1+EcGLmrk8;H#OtVj*TH& zU%!00QY+*?f3M<14=K~EC0DaD|IM5>?b~I4`^>FT=H}ZI54VN>TNo-A@}=|7GT+%@ zn}pgDCrz3(%PjZR>(}1=*B32X#4ByK;%e4Jk1rn{9zJr!#i?W7bMMxoqN2F{b-s(A zrd3r}o9Es#vA2(38niNG)tx(cwB$Liq!%;1lBlXnH1T7|I?%xAJImza+~RLdtyB`luOjQ2;{r&w7iH94R+3o)SDb{#?it;^m>7$h*Pjem6H$dNU%yTcR&_N=_l z>o-}=S4KuADJdx^C}@h;(^a9X=h@Z%x?6t#=8YRmmMpp8pB@>u_#y{OlUeSqHSzoZ zeS3R5`FNkNm)EE7-|ufoWIk-LbLY;M32wbox{;eyG&MCt?Vkh# zjEs!p_SfBA=*%7+9qj~4mQ2jdoQm1k*ZqCmZ!aSw<2T3R;p^AYPt8|f%!rt_R9UsH zyZ@;{}EMNOY(08-uuen7xL+a}4Vq#*#!o;RqhyD{SeqC5tm?+V03DYc*zh>HjPaR#WJ$9A9pO^RH=+UFz)Ac}|^$tPhTU#<0U(6`E9j7(*l&bf% znEiEo%irJY7Sm0Vcy^>ySm*Ss`h8Q}+Z}2?J~}EMUsL$~U2NX=3;DeQNl8gbiHQ^E z&RrWl|IDU0z0&4QO-(wwy8C}T;OWf)T*IHv125#q$2gie1Ew)u(Gz<&^wlCh(($d1r&IgjO ztE*eLZr%KvPbW7fAKzitxH^3OJhR+ecXk$MZgt4YdUbiZe>W$h|`9TvsBzYXt``=w$>>d*nBre*UD4PY<-6NyrcGth`FXb7+}!GZb3o(ta<)}# zqPO=Yf`;_A=iZ(s0O~)ycv11>#KcejrJHnChppZ@JufdWzUCuqZugDd<@vML%$hyB z{LKwRO-)T(+t~S`C!ZFzCARU&S`|ES$je+GxA#er<@Jo20!l)iEl!C=MMY(0+q{#}12Lx6e;bPM)eAZkBs%$~NOQpB$F@zkB-p`DH1U zTD~`0+d<8fNwMxrmo0l`cWLGMdjcFRi{1Op3=JdqR+aY4*>2jr`FOv4eroE{^ACDM zO2zY*O?)5d1j~_q2ye0E;@$+*aJ3*P{d;R}^e?Ncz?A{|`m~vu* zq;cAX_JrSGU#F*~rDbORyqP|K<<+eE`u~xe(^^}=ssHcq@87?FKY#xG{T+qMe+@6D z7oU=mVAv|_%gtMLMRGyj<-e2NITrf!FI+WYdDa@^+238(hUrIca#5T7_4W1jKK#{| zmX^%yd{a)Q$Xbv`>Z`RLKqudl8y_n&W9`|Hc6r>A$9z18}? z-2L)~4F)rP+~i+gyu7RQ^{=n5-K&{uetbyG$XJn=-hX?0{`@}Tyt`Amix_ZYHE7XXI1hdVCFj|(16I}$CGExI<;v{jNW|fa=qBj z$&)XCdU`t3fB6>uQ=rbzlP4FF_W$|Ry?_7y?c2XUIXRh`nK?ElM$Ko2!(@$VshPF=J8|a-HD))+s@9HS5|_?f+MD#J%9fG-fHu;!izx_&Ovthn0+;o z^FuSY-rBHXgOPhkVd2Lc8&@b?ctemw3kt?pTKp<7%ZR8BnG0UBc~ zDEM%wl{+>z_NC0--1^JkJ-k8P)(`TF-FhdTOxYFNCMG6UWZMw<_;~;GU+=Fz*Iu^c z@YK!4&;53J%x9kJ=IyWd_jLFsP)Q$J$0}d{r*NiT>(`soQER^)=@gcelbc`r?PfDO ze@$)e=`Zbn)PBi?czSvYtNA>r`ZD!o%D#R3#P#Er><_$hcX#>SyLWdo#n=Be&7FN} zlTI%0y62AnmRV$8-x|Fwr}EPi&bNtE-}OE|-d~)!;_9o|-DMZwcfY!_^715A?^7ij ztIj`rAt%AG^)A2tABD*$!`8)QzHwlvNV%}U@rysh-_>uQJqvp}RY<~htv`3lW)7a|ytE;OIxA8{rud_9`X%1WM>*e)p{{Nry z_4WTxPF9z4h$ciHULRm-Ds$ zmMZb={r>;U?*6*Yva33Nf8F1{*P42I=T4ryxL0OPOiId<#fu+5J3G5nd1t}HruO#s zck2p@ilmHEPMn=>&daq-D*Wq*ht5^K_qJwVzhD19_Ixzk`T6$o+j1fk5*{?Ma{JA- znmTdfLEfANSF?(Xit_I5iF~S{!E;!~zHZN%uoEdp;o;XOOc1y`QRv)!d;iI*^78XR zX~S~Qy!ee#YyW*bF8{J`mO}f z+EVe_oh!SWTU^g)mPzLKcX#dV>~?*YY^(h7Au%gUOL0l*>uY*?dZB%1|EgWxsx{SX zX@EvfPEOCI-*em+d;0p;et9v`ZSlqp8#1o1n>%-I?9ao_degH_W|hmB%t^i3)Wl?L zyxHiu`tb|QTTgxZl@mL4xj%2BgqniFgHuzr>;L^!&wX{qIlpki<>2?<@7L>J{>09( z>dWWz_U~_P%|10n)BfL&#h?k;?kQ75AT2nxD1RXe=u+nbZm4NtB5Si{($RcLz; zGz=nFSW;3_RJ7=DWWSUtm!)F<)pvU73(h4(e*5t7u&}z{64|s(I@Ry**=A>FTU%Qv zx3dLo{P5w!p+kojI=AmDtu?hb&AfEv$`z4jE~Y)Kzp@+Vc`%*=)sb_5yxDxdN18K) zDO2%-Q(oS?XJ=>Y$L*025C{kem@sXcn6&imZMnC(*qzx8cFmbH2Q)mNe{W9T)#&LF z(b3Tn5jJ&yBuwQP^4T*L9VAN}5;|Qjffsr(<+p4Rs5p4;+`H5I`&C-*?5Vtb`SRxc z`};tqTNW)5lul;~d33B-+SAkXDf_Fn%x~VjiQ1YaDkkKGk&#ZvM`hsraGEL1Bhj?yXK?bs1S%$+kp^W##YhNnB&A=+jb1*dKGw>=dflh()W?UD89m9Q?$DJt4Dd-m*&%pemAzDe~SyV1zNAi7PWbl;lK&NboIP@N2!idVSm z>goam1CRABI#yX-EpJtF;zr;VHRzHUMyZKR|EEryc4^LpYipRR&1mPh>HpT%3YOz~TB^_7wS z{f{*dZ_S#P>Hg})3l9!WJ-uyPx2|2bcI9u#GPe-sC9MbQTU%R8ob;w&FPZ*6Y2%Fr zua0M_Pd`0T>=plG@LISK=ALMdKgX|%O`9@B!hF*A1v_@!xS6|Pe2)J1>oSB7XMvj@#q0I#%LVOFtCAgguPT(McR zW=UDM++v4mP!3ul>gDbIRcB?;%4cV1mw$V6Q>u4)QDtSNdH%f+t*KShrh6?lmGYn4 z0$#?qKx)Bh$rY0&+B`iyEejqr2;NS4cOSOwrfS;qt*ysoEjC_r1h4c9$i2`Z@@mPQ zJ28K?U#}8By{Tl%(cR_mU;PxDCHCm1Q!~tM9^4E4PMthy7&dKbfX1cEmpc`8w`3{@ z{gpn&1U0C4f|%3XC1I<3L!)QRp8fmJ&*J8LPTAbOq6NCG5O>{D&~33gcIM0)&&g^> zj~-ngzu(M@VSW7mWz3h&v#4RG_;=9xrFTqzKDT^klz%1t)S_#3k!d5`o#29okuI+U-P|?1!sb+mQUIq9=YI5kTds`_^g0NuJ5bvB9e zWxi+P8iS@S0tt1E-mlikCcw@55tdS%$_R{ZtV z)yK#C#YIGVq)cBu?zdmJPLU-yCuh!_IXqR|b?145+%FwkvSi7N7cZ>K-yJz}L`7X) z+FFKzq2ZU`!T=4CuJHA7Q{z+8(w@D1`SK|kJbaj#m-p}A;nkKabX%}+v4IluV0y;WE5->-jpY3Z)q0LeDXf(Hlo)&71lx$AHn?{Cl?^O8RQ zc{Vq96ej!l^vs@p`^Ck@FDi&Mc{_*3-bLY6Q!W&yAJ<;$0qm6Z1E-OH(%mzURO_o(BRQ}dN;TRwbvco@`@{Pret=FfZg z?%my8{{O>a{^;#_pe1aJkFYT?1f2I=8lanx93p*2eBAc=+I8 zvvV8I%?*joAzHdxT9-0R)YQ~?rA!vQ*VWRRWmWp>>+9?4{_||g-^Hv9(VBi*-Dk#z z?Ca|mI=7d-ywoZ%X~ql*5s@WVv!0)wZC>=mW1`0q1D}8Z0Yw#M<-->;CW=Wd`L?vR z`un@TZ@1qM3J%_B85b9KcX#>wo14?8>&0H!DZ{|PFn??Q{e88+zXdPzQEa)hvsm58 z$Vf#+MO|Ip1O!Tce0T_Q_LgMMIL9cCQlY~J-{0TAzbVz*(ea?a{og0go`JlyI($87 zMONzRX|ArW(cAM|4&I6WvR zC~8~I%sF%BOqgI$^u$A4eEQql+udDVBO@bOSy(_b*=glmtx7gFcTS$nJU7SEL_I|! z?RcMTYin!Egw5&a`(&-JUAbbSskt)q^0IyV_I&{@r;#?x30QqqY2u5Ahub?lJ7ag3 zUA0eJ(#p0{_VCU96(5sMOi=6=*I#D2aPQu~i(I?&?(YLdpprTZ1H+29ZT#|ee|~(N ztnR;S#|{A}Q2y|@`?=)LVx_H2RZ6Y9+V8zdi2T;AzfZy>LqO3*M{M`bog2Z;Hz$w4 zz=>WZ^ zwR7j){ornmRIl60kfPS9UQZ{fdasM$e{XmBd)IC;LmtqKrcttZI3Fll9HS(prLAjz zd|0wX#i?WU>ery*@q2rIUJZ{A4KLr$z`$^TeXe!6UGcLs)8p$rIXe6M|NnU0U;qDa zdGk7^pA1`D1CFlFMd|`S4#dag_GBD1H3$MWp*^eEr|8YuDXWhDlzx8R#EBCtD=U+a_vPN$kO-Pq$;hax z`n4hP@V7TNFRzK*JWV&+Z=Q{%vGL?Ek(ZpzArdYJp3g4WUH10Z&*$@BmIMS`*phj< zN1C63fnksQ%a@=)!1sbXvHf z_V+i?I=p5d0jDQLJA+nk*|<@#(?zr{tgf!k$H%9cjn}H|&5aF-&c7xoevexj@@tv- zt54uzv`^o@*_FJQFz+_UpS5MX%ic;&7tXuAE%(ENgU5Q=85kJuNwc%JE4%mo`28C+ z_@t+*LdWdT(eYc04I` zSTOJN$B&#mJSr_yrcFCH)A;y}hatSBA_tqpYHHIXGv+hZp!64d+?mo4+>&tDjOfr){^ zLDnqi#s)FnC;_K`zh19j?mu73dJb1Xrs6A&hV3dsog1Ai>L-XvwYX=3mLAvk#b}85 z%``fC;~{7}Mn6aEp_#_%mtN-H-X>{2gKHt!rd7H}W@ZS;Eo}uY5O{fHfn#&jww%m6 z2@DJj4f%Gz-)vU%oh9JZCvQJ*^5o<<1*e>pgC6X!nkx3l$AqK*(jjhfy$hTnq9C_b zs;Q~XuldBOxMa(gFPD~jALCoDW1__R&-faHD0l{(g_YH|^3#*OwHoJKnm?$5T8n2- zol9fhWjfU>bn(OoJ|-OTZmfIC-^X%G8TAggwPwlCh-p^JcoS&b+Fkr>itjZZ;MY`86l<>ZqZJd6t=J(s}VPRoO8!fc8 zyAvhyF21?BnVE@6MW}Q2>gB8jBSx=v()DA zs?3}@bJyq9<_3)p3w{?qU0#}ep*TuIr0Zysu(0q)`;z6Q>0#cdazX26WQ2u zy%N4RYv<0Lvu8)=>b*KB zJ;c|2m$#{KP`9zM>5=92-B_INGbRrxtBBH~7;uzK3LIhEhv-TnFbdHCa8%{iTH4^P{g-4b!p5Gi|k>FAA( z40~+b5+?{VImk|%HZARqfXDfHwl7)A{b#CQ=n#3e7PPnmyoUI4Dv%~lGcr873 z_H6FWO{(UP8eEhvW$dK%`bFfd%rzPc(jdRtDQhR89#mXep3T$LuS2w7GA{oT)B zzoe|^{BLE{V(9Ab_Vx9B>0p$al9sk?`Eq_4iwoDTO`E+kXyv8Lmz9;3gM))P73=^1 z-M)YSeh-yZ(-I2Qq*}x?6U)lX<`xtdf^2%fV%hI2ObiCo7A;b8;*h`O)aml*#fup; zX3Us5GxJS^R$F)AitTZGt5S1w@2&`3{HZ%ADCkMi&zF~%=ilF#o142fXl2}c=^aLH1LThbaM{fMhyu^|dHRB$D{%DO5oH*VYza4ON)_}Q>p z*CjkI!2aaZPg}N_R99CU8*l#PJ>Ra@iz87&%@zda3M8easZBmnXfm|U&c&CPJ&MMhLaP*Bnv2accWx=*4*8RqG> zRIOXGM8(#2Z}RcJFPzS?I;Yjt)P$VedL#@pE+|x2SM&4pce(^E{`ldodeid>VoV|G zOIjHM!Y{T=2;bUTv-{cOM~+b(UnheHX>zZw3jKM{MU=}mEiLWxGT-Lr=Gms%-|8NL z%JsM}E?rv$Uh(MZK8e1p#?VlHWc8K{w{Fe)ps(wat9Yf@($W$%K2V-}YQpKK`f+;} zJl?%;dqsho6wB6DhO3_2w_VsVk%1vZ-zs41yMn83N6z1JTGASDUR70f>Xa!SOE0Zi zv*ypAKTDRVgolTxr>9T!SaLCAOVrwcA92}BetZ)?SiHxB+toQVIb9KB za*%b-ul~4X+k~=$^V_rS_8n#ZP^Tf%6};R}*0##T%4(Ltr^o&F|9(8~zjFz+`sc@w z3tcX4i9x}^Z{NPX@1IaH=eWWi5AL1T?7h$h*;KqIPmcbE6e+0L0ems8Qq%xufs@4xrlsy{z<@>W^> zOHS)ty?*9(Gh}J*i(SLOutz!Ws@Nq#g&$?ie3^<@{O|56<>uqtwsB)&Nr{fG?$=MB zwCo*RPfybYb#teBMQ_Xbd5_6CccEj&M6sg1vBo^X`<^Q`-0H{|*JfaN-*NBo(~}IM zChkvfe`t8oDe_9)*m(2G;N@YFYhrhoy;qco_2A|`)V(V7T|Q5d(+cB&Xby%_p0h`` zMCr;`&3*Xm`y)|3=_k?qxB~1K3a)gl*tj_(s-s2jl@K^U52rTQWVo*iTg~}@lfH=( z>jP00akhtn2oXCOI;>qlXvcIIbL}dtE0VU{n@7Sa! zGBI3{nP;xFR#E4)l6uST^7s3yzMi@fxM%5_HEXV1x$^yk>R(WtQJZE5N zxKMb7E(^m7%df_|Px365$yhE*n-SC&Xv{NLBXsr7%&+$UL;jx$$u{}FzrihdRoL`Z zSF?KE&hG7w{&jwpNtEv5ix=ukq83}Py}C-WZT9+=-*wYmofbx@2z9%tC2jQ3FcIv2 zSu%4buaA$)m-uG3Nkx~J331My_V4rlEso5d&20bco6b!<`G=W#@uEc)77pB}H$_H9 zet&mYTUWQVw6wIWY~6=Lb@%txvh&OR`F!3!yDFgCM5&hB#*6!6hsY}iJ>4g_k8*{q zXXlseku+Wwx;m`-+nbGBwy3li@Dx8d(D?lPeE)eiJ0mxz9pe*N(D(Rf!8cP8iRoq2 zR$b-w@$val`0&Uko#}f2@{JTdFRf`nUjK6e);0Gfz+dk-ic*Nc+ zcgN$!LcRx=oL1a^^ym>6H+Oe;_shQ?P9AY__onGaU%Gs`{N0_KJBy#!{r$x&W3j=x zoiFs>hpI0xCORl=*tE&Z)3dOwY}@wj;v6id`&k*bN}tc>&ekx{dmFOyDsRQ-=WYrD z7rval(RhXVzfAa^RiK9Q%}uH5{_}FKu9~`gcXcxxZ&FgyqQ#3RPo8Z5<3Y1g>wMel zu%MtxGiOHD1%7qPeHiT!#St(!;AO150c2Cy#?6~2d#IEw+qrY+>ebm*RaS~Bb|6sq z_*k#g!h#nU77DBTm6Vr1uX_|R?e5NE(6&%%(<~Q`lc!Ey%B|Q|`T3ckvfG~bzDz4} zUrcqb_2&M+eVf2}%`5qPytp4fe*FK>XMc0^?RR&V-@bKA$jL;?x3F;I)TyCA4{Z^8 zZyTet5EOJt>5957R?J&lzdAzJ0G~c%Vs0M4Ijy(3nOQOA&ySDA&(D<>7GBKSy4-KB zlx0!M{<^dAuzvdID$#U=WGlmzP;oJAVO3zi? zUMG6~mcXIvW2?6qSXo7_4da(IYI)YuA~4Y-B{x@BfaBArPkM2CW=x&BH2U6^D_72) zTeobnQ`8T!ijTtm>D&v?IYdP~H{{uze7x__Atxt~&(F@Dwzs&rCG+yj%gfEPujM>D zGZVC>>hIU^h=?0EH>dyo`&V2q#-i@e54T>aw>LIAuMDxWumEigdHC?*?QOZzHWeET zx3L{y;FU6Y;GZ7#XN~Tfm+9Q4(i5*8`L%G{gY%nPbxy0PsCX>BBxhSSC4NiI&rizk zeP7<(EWW)hciOaRI;Zz-zc291aYeBahkj9p$^TX>=FG&oKA_RPXvTh>3}5 ziFAQ>nC0A~SeSVJRyuC5LSfy>X% z&X$&9JH2V;%9ZMV8%3>cVQT^AL~KeqIny|O)l>epZi`QzKY#p&)(5pcziOSMB7|SB zx0mQ$wqVDM{Nw&te|*nb=(O;}nKL;zHYEPsca4R~@%Oj4oQl`i$H(t1Qax<2Wy993 zrnPO0+!S?dxc7K~GdE9MteNB1>aVY^c8O}|3V+jCZwU+7yE#1ECZ~ylC z`uh(Lx7Yvu8eU%h-FLRx)X1qZdjEgDUfB#r^+&z1~;*Tgo(R%Jk{e=gnJpd+r(a`8A)e zhQ}A`hiZk^)z!7?2Hsz?VnxQYGc&{2#VEGy*sm0w?l*4O`E>OK9^1iW* zDM?95LBWUb-mME;y)-EEskXnb@7eR`{k^@THzpl@d3kx}Owe|S9}nAi-88!5#w%rF zP{#4U+rIZj$=?0x+!Mw28Jiw{A}S_j-L?J5D<>{?VgP?>2%9kw-#eTU!ItKfKz+xk~V@ z-IoK6%*<>&KVGd~f9m?VjXJYu%<$j{)DYonZ7TE3%FTWI;K7534?!!h@9nLgo@u5G zUflff*|TN!`$U9=or@10?H2Esx8HYYp-x8p!-o$aHna1`ZOxkMwivWZ*2U$>larG( zAGvF*`_Hv1{q-d?KK{OIx7f`aH!`-~k~YtqV^?dXtLv*by}17FRIjK1{{D`fHrF7r zY3b6`soMFswyca=3tEvd(>Oit>@3sTUnRl8!E3|1<9}E+gl}#A9b50- z*XHwN{#So{!^z9L^zy-j2btM;3d+m#FD^Pd`(5cJGoxK|D}Dn%#m`z_1!`J<*jpOL9dNlo;(BOdVWG5HPC;4Ox(z=y6kC=x zIV~)>v!n3dy?eS*TUHcZICSUEo$Kr4w`X1T`hR#?^!7Z^UjIH>>um`Ko0cqDQu+B= zWJJUi&EkiLTASJV`6P`}4!7}cDe_-@amI`p1>bentX+FF>Ep@C>bpx{Z_Bx9>eklm_xJY7+tZ`wg{o-O_n(8%m@?_=7CwG`0IeN7A z_uK6)6Xsf%e|vrX{g;=QH>aPsD|>Te_wrVe^>tf+^ovj5@WNT_mE62LC!4b-PMans zAmG3exxa4j%$YYgr=LG``0!M()QE_Py1IXdTDfIqWuF#RMn%n9zC1l4VZpp!CvR_W zQ>k7@<)9feXReLi4eByxUtf23Z}s-m$zRwm;FMzrmE1% z^82;vd3o#3pURw-m6~drdP?Mu>dNGb(#E6b@89?y7R~YAzh66i9jMrh-(Pp_+O^bsdecvb zuZai@3K9|$am%~QRW_V)HVe(X5+5PwnjNqPOSq^7FsHx>{VZrFz<=dpoYCKkbz^pXRl6-n@B!etvAZ z;veMKOP&1uWYdnGS$?+zq9gXsTe3{bx@G&3`Pn>;4i5jjzP`O({@_3(XldqW6-7lA zZEf#`0aN_B_2c$y@tG3yxLS9$ z+uPs#pf?3P6R_jp>)6=Xy!Bip19*jk3M~Iaj~bTXR6zVgvf7)TDil* z!a)7X`St%i=LdfI@F8MbPGsxcU3+D(h=Il+6!$)FzvZ;TyW;C)-hB6k0W9q7^44W* zs=vS6|L>P}txCThXei~{nwy`WpD!*h&YT<=_4WIopPxZ{HKiv$d-}Ast!IvYSy(ik^OSExAs(So;`cEhsv)Pi~HGlBtUJ{2M-=x zT^-KO&Tef5n)LuJSF8Q?EZ|No!o%jefA zwOCkNv$L=iM8AFXXwmai!NI{#i!67^txXPyj#z4SxufLx$;tIC?ecM3R&4A&`_Oy( zxA+|n9Gr6I>-uL$PkpZ&q)|4{>gk;|GxOKS#Z;}inDt&oZQ81<@?|d+>WWPn7GIpQ z>S|j4x`&+|eSA0eRE8I9C~xO0@jLhWdZGV30VgXf>)W?)b(OrUJUR8$%Is@5qN3K# zH`U&~AmQ}K$KgrIx2>)8W?5|fGi&nY%i-_u*Z+IJzxjCYY?B-b^Z3Z9s&CiU2Ak(~ zv>dwl_xJbG^5yb@Cr{13c{6n9^m{_*=jkp1chdXiY^5v;5`HdKn%I$gv-?VHV!De^?dmDZCQN}r=p>uq0VVg_w?GevVVVmUS94mZ=QE&hGFurZ*RRhJpKIm zczJ#O{Jwqr_V01Oy^pW&Y?DmTW+<-u(%)ZSn`d5HB5Pf?CF^R|?{9B+eYCK!2-#UA zEiFCEe(|{t?;c)d72B)x>~>;U`lUlx-n{v#{j} zOS`ob(sTcQ4v*)3f3Z0&Z`GwsJ$r1nx)pzad3mO^*^=i^1f1eh()d1nd9&d1so2P< zU-s<$dG?i)1TG!9wyiPZOooX`dF|BMzvr7~Kbkx#<7CH+7YjBfFYl936yVT`+!_D> zU;ftXya!fqxp4C2$z#W!{rmfy=kS#Ek1y8N#ev3sABpS7op@SQUHzNi{?CU`PfyP_ z&yU+%we_ZbJJ(Tz9tVYr?`2PF9=xzclOa zuCE&s5BtrxJNsAl^M?;VKA*Rjm6186GT~|Mub0bJRaGI&Ig&QsxO_SIf4u$1h&^d% zXZg*wS{uDxPfM%IZmQ$Wn>Rr_Jg;55*38a-Z)frIJ3EW{x~t_-`}z6)QCqca)sOpVZQZzG zgMsPa!smXO&1`>nmn?jLw)XF=iy3zBulJwkjGSg${aWnwCfPZ2w9b6j*clYLBH%*j zCvVB#wC5{>D_dJPyY+s`xctm3=S|Po*WKP;N0ct!=(qcnbNkz|zrWq};{T;RK4yIJ z;_ChXZe2R#^XdN$>-Rg<&e`|bPd}~nX75y0ErkV(9vx{~9OZs>R}}Y^z^S{swyq6( zov}J>?XSyWtG9MtTN|+^N+h6=`IL}gD}&O4H=-x@J^6lI`T7YSk4fA%@5S}wrk|et zeBN%A>CZpch)>SU%7_Sx{CW7|;;l>vFPdhzRaY`HWJr8dF&8hJJ#qT?qw8XCwzgJT zSX)RN0JI3&)=MPch}5Wvkq~&xw*OByLS(i(a)UOvSGsv<8;vSO1;=! zUw(Xi?Ak3RDJj``^wEqNGftfFSQ+x``TTmb%(@(5jcprz1DrY!Ue#CO7M#nOhQyY6G88L7hZk|S~9Y$RC_1;VFOj+tl6_?>ql=}bC)@J^G$AXy*cLjaejV$(@(Q9 z%$PCb%bS~*Pn_U5=VHji&B5W};ZgDHOQxeEBg28CM~|L7>G@GEI}rpf-cdB*$@aUV zdD+s->~iG7DLPBi{Lc9aM`z?_X{mOc`0?ZA`@P$fewAcc9X8Za)qZ_>e%&OGrS1EE zriJ|fC(*XFXs7YD_4kYa{b`8XyMXUZ?O!Q|gp~#Sot{26aq0{NZf(!6&v;?r@6T|c`OBB$jT_f}`!>Hr<+%Y zeSLRNmcbzN#f7bnjoEy1dn&%Ko7#Eu`@w@cY=s~QXJ>10SJwRd z*U>R+*RDN}9wjn_e5$zZKbMK2MqVZ-D}jNbV9Pc(2Ax?pl37kgH}12u+n+q!>*@0+ z=~alav$Mo9i$kZgzces*9lw;gF@mSfL4}E-#;oqot_2Eza+m-5GilO>6CMl)-izy} zJ$)|y;#Hit#|lvnF~3)sa@**dec)6>C$;pc@D zCo+d|LFpPu_lwzx<5_D;6>B$(@`d^kTDB*@FXY?Pd=OEONF@Q7DO6qx}D0 z_vMV$Y3IXoCeNE49TK?l&Eo!56?-Nd8U{6OFfoghmauSE%-gW@oM7(JGiTo1{p&j0 z<*~W;+a{sNxCvVY8XXh_IJ%ls5?8OV+7^&|?U0z(*(cMdd~j7_aBz!@d*E*`I%S&P z?-$B!bL+HKTaQ0yX4q5xg@K{P)lDok}+XfPw$sndLyxTuJQ`zBBfkmkxgQYZaA?pH99D0aBFf;6wWMOackD{ zXS-Timo6=3VqCrNL(k%^Ti5UY{q=~;d{%A=i@4Zuc7_OPi619lTwEFR?(LN;N1mT| z|MvFv<;}~dcb{(l^2L!`{8Gl237WxuZf){Ra`(&GKZ%0Sy{CYY) z*wB#2s`mAv)~=29@;q`iZ*Fbf`|x4m%a@C5YJNB+I=Q*kxV8DHMH>`8`ok$KVlO@O z=TBa~FOR;yUUe&Kj@##g5}WOrmmkH~c|I+=|NoUX!-4AB-*ORKPhGwl*?#ob-$zFe zzI(Us+qbUuswqZ0WBh(yGkYiFC~LB>ZE0+PQ>S9KBFM`EBJDAIul``NG5vhD!EZ|E z6%YUO0pZj89(So9N;A4%^D8qk^X2C0`iuwkNHo&WHL3zEi@Hg8%aXD4%W z`}&Rh{=Hl3?Ogx&Yii-gZxbej=!L6I{~o-~VW}&7Xe#)62^Xx3!$jIeV6o zp~tO_-CI~#RxI0LeVmNh?89mEZ*S4mF)I4KGWgQ3{%P%tA9uaHyj{lLF7N6p*U9R; zt*w|Cf_C27pMPI(-I_Vio^LNcIq8{IxT367&<#%&}gljXNA41&BxAUTt0GU_HBWo!-v-yWRV@Y%ut=aB{Ejk!NQa z8aBOpHUHnQ?1cCCY~Q?@VyQR#?q5#pCs;+>JBR($cv$;Ra4cIVE0D=2DWyTmH2 z{%S+S?Ek-BU*{G-A%3aw&h@joC$Fs3Em@~q!g_K^;dQpROeY~JS0LBn@umsCt~t%h zT6FG88Zti>cu4_GS)&IYdjEpBEBSlS3Eqbr5jyAXE<~CZgq4nHWrr!FA zR~^D9?33k~)XD+QHB(wormXV#tiiuDt5Vx3iaWHV^NLLLk3Np0k3MhQ_{`1i{mSjj z;*TFnGN`$G$HbOv>IG4iV6X|QMH-5c9iTF$CCs5UbK0ua$F@s{o_0Dl%X9XVm)+vu z=i5lWd6$=*w(RA`5-}^2Z5y;t99mbpXFLwtAL`i-9uyFI*-1~C2CbnWOmrGc^r9|I_N3pafaZiL5e$K6#Vqe#`YDK-g zrj(r+=;|6-x^?qfUG}9-OSCqppAS3Vm|-%jua9r`*|g0!3!L5E*$*3Zx+qQbIFht+ ziCj^%j@W7IL&@8;J?`_dz#{LI6KkZ$*(t_dn zh0~{pXJuu@#Kg3Jaf{ruXU`0;P}aymIbN%-Q2tt;*itiwy};nd+5lG&4j?mBUGE>LlqdYtjGNRabNweJARO zd4JsB+uOVB_|nvjII&e$x#HmRp`)*f%P;cEcCoB{%in97n*PtVf1i=N_s`<~EqbA6 zI$ylFe0}}={}0)#xw(D&TQ6CF*0fk=R*7(&T38wBp;wD2XM(Odom$j;&1q*$-NSEh z*MEOMU8rUK?%f_r7p)>sq#3P#{c2TKw)I+_Hy4;hIb4`c@)sz-FFX;|@xtfD%a@r| z92_i67e59#icPxabjoVtrI%WfW#MymrksBIJUMy)t?m9^0+;{%sf*tkv|TrU%M^u* znt!|b7yD_5>}}Sx`2ID!(IX@#@Z&pWM}=Jqv%-@=22YWYl!O_)L1RM1q+i#Z)}D5m zb?RxJRaJ=E7A|o`YQHVKv?YGf8p`EclYkTdn&+7 zfPrDHagmaMNTbsxfk;pjH#j)hm!GlGLBUGSE0SB2yZK;%>vpY4txg*&{3|MaWADBR zTx@o3?rpwERn?A9vu2e~X*HVZ<0X)3X?b<)*0=9m+K(EUn$%2BR1k<2<2ov)6bTB3 z7cXooJ~&)n?4Tgf!PmyMX~M6W9-%3^Arpl&W2%1jE?M$$-n`dMOexVjQ^=KIxANt8A@FK^T%fH+}pQ2 zGebjjytrnc^r`t5qcZWvj9KR-jgK8Uu;8lrC)Y)fPI)=U#u}&SE|IXbv`kG+WoTHm zXi=E-`8k%GFN=rd^tEz}uZ!I5*39g%NucpX!ZQE)|2{qSp6TNz!17Z*Pk=>n`ss&m zr>!5hE%Rq>bSUA>Fp=8n!*~7S!}?#h57_GI>CKYUSMXjD=q0Ab=hhu`ZjXl6uDP>b zJ$&)vO3g&`oiT9{Vbi9}oO$Xg-;1l+Q~LQ$w5ElHwz|6Q)6nXw{KCOd@aEOkf{emC~y<%JhBDx~+GpJi?B z>LTP>6c*+cCKj(TM^YdmJMG#s=aND#ZS99{tK}1R#@ve#zLa4iD=TX@d+p-I&Uss2 zC2N2;Bt_^-G#7Mx!>W5x&h`Zss?S~EPj&MkgO zN%`clWo>PYe}D0Q{P3Z)e7k{JSkS!CD_3f&A2@_LGVVNn_F&?U<^tlt6rFy5$p1t~#;=%azl_6exdPB5Yf6uPoc5vIH|7zuzGE7`ey}P2t zx|ijpRWrp-I!W}W z=OPm*;Q{# zIMi}fzV61I#-g~rw<^EoJo@|`+$!arUwdtTx<5lhdbP(KQkQFRrv{(9kYQqBrdy=cnzrlu)2F@jy=wl+tXk&#`59}U z?Cl>}zrVkpUiE~7LE+iO#qHLVE6TCz z^((3JAa#*E%R(mwA=!W5ZeO&sE0I~AI3q^y`sIt6pBAj$mjCrlNyM7g$!d}>UZshO zoQc@~@84wgqEiyDx165BWXd0~;j=mWOP5uj3LggDJiDPiXq|TySvJJ z_i9(}k@oadV`H1#&YK-yoq8^B#`5P!&(3zP|7&Wgo&D`tZyq1t>!v2QojbRhuJ&zT z9D7yJMM?0>`n*uFRcAY=WI04?WZdIia2d81Aor>3)r^0;lpzf{7N<%L>1dASn{S!U z-?)8)!G){3Ygc)lI+OE9NA|e5&zu>5p3k>-a?N<|1XqZ?BZnbcvy7)=rJY+uG6Q4jEtm8lqPz$CK=v&CML$m%h_4| zD@1Slahs}?1*>=GeoRW2p8X+ud!CBw*S8lR|G2ev-M4SO?u$~YQ_r0{dFk!abY$6lf>ty8j=t zSMzpCHg{rh&i z{l0&dMe7v(Z zx->#(#vHX@51n1@gVd#^H625)w=XK>=Hl}5UR`0cF@)>u)a~7;clyr$bZ+ix>vFxM z&uQiE$JPA3PJUZEZ*Gj9dWsRx;i?-q-$w2F^6+4D@v)whdOCCV7CwGg{9LN~fR@nw zi5K(#{kh7{FQnDMan&l4!#ry1=fmsGuI{^@)v(89)>q}LdxVtpB|XHH1VE)hhUlqN zP8sS~^ddcue>|`5dns|Fkdcv}i@UbeK^LKukK*e-MsL%ZdYYBtfyu2IjLdoe-q%Nq z>7CKnyjk{X$qKs@!q;3Liiw4Ns<5%Ee?HULRaA6wO^pH1>&I{2WaZZ@S?zj%XJ=kx zV{zS&h0E6Eoqj(5{CtChH?NtQsj5ah#yLGdBceBb!pTisT-)YZB&H@kdiUZ)k^$S! zoma11eY>|{VIj|wDGD4jd)yXZEc}yNSUA^VflqbnIXye&!(Siw*ISkGNEzt%^wvjh zP^}*t;KzB)0)U9=~yEkpxbZ_tKX^PE_ zD<^kO$(rEiboP`Jm#$gH^vkT^rZ7jZLu4dJuEi}?Zbk;VzP``uJ~yQ0_C5acvTofv z=IZZlzYC>$k0v}374?nUd+Yk$yBBXsz4Nd67o#_Qzn;E$*HIldW`+j|saID=Z-25z z=l#y&#Mu*Tf3tmg&(C0BYH-7%{9TuAb(gj_Bg46E+jdXWX>@E`opzq@=FZQKRaNKn z?mk$}wsWW9i!Xa0KCC``_~E?w&CPFhbr=|mkM->Q{Nv-&>hE&t>H4#5ZhH9p>r6bN zqULt&s3+&6^ZENEZ*E_|z5Ddi-`|Ztf7b2kotk<7)lI{fB|L}u;&zGD{r={WpRd-; z$gpDFTHn(rC!S1kJ-g&a;{lQNTC37N_Dx+48s`1?r*c!lpXu>+HwBxsWS_X)l3B7f zVDrP)S<_olOWv3V*LEdsmT0RrtNXLHrRB_@KkL80=a1fe?BFRb28ZJx-zqA~`T1F& zpVR5>-!2)Wu&k|3JN=xBw&#mt3-4uqu{)C6rzp(H$=Nr@FnP~<{m8Joj`rit_v_cM zTAOM-a~D@+?lE4eDb>$<9(mZzotw_iBD1@!*KP67%F5c$C)M}95?yT9zFFU-Y&{J647pvu0|3mD4!6`{`p3FRv~zICbt^TeVxSlxgCj7Cz}X zV+Hj~VUE`hnJcIVLAs=;oFlUp=d9Trq2tTHfpBA<>omC zrUncQ8)lwqove2D_k-p+;p-E2%_mQ{XsNk$FTNBO1^LKiB_%A-xEhta z{>;s<8#Z^}*t2ueaqrV7SBi2JNS(cU@7}R9Yd(GuSt-oH<>g%(p%d(UIPCl`*)5ve z4D|K&t*orHw6yH(=G}Su__45zj7{Mq7tx+|ic8+Fm~_o)Rqgt#46aGsruy8T6NELv z8KWa?;ToMQLFz#r)4NZHZ%)g#C{i)Dx3@Gdp3=|%Q$+T7?Ay1q&-`53k>@_;=>dZ^ zPnjgr+Sqe4^jQc`lWR{dE$#a%KIbnk|&tgNtb z@!wyiLH(eeToQ&!KR!I1tnMFn^uVkZn_ZKhzS+HCVaFCRF6q`otAx*HuV~@-j#2jYoqo_L$XQ1}y_K5WSMA@v(=gzfZIIGY|Nozi?w97J zB33$L-3LDu6=vSNzUAx|i*!G4@7*;&i&9cj;^O4)B&Vi6eg9rwK;Xgq_v;w4-S?rv64MLlZTD@`28G>H-WBE55GA&D6*$U@2yjQbyWK2 znh>qp&4KIxMy=g(OZ3L3)YHf1>({&tR1om`{Ojv%UY_dB7EXB+ZcDH+Ob}}E(3$43 zG^z8+TKkO=On=KB`1Md>75F{;`IHc3R6Lyu&${wdhNHL$OY`5;rcFr z;l@e&(@#G&XtZlfG?C)X+x}s{WAcyBE_vJU@b|g}-;UjWQc;s{O{=5X%{3>(S3Zxq z#gbZoeF1O#Vkzc{RR;{#IM+Dtwm4^ICSLa^&{b~9gST7UjAp*MwA6dLetch7S5tLQ zZ|~o~e-|oTxp*<~qQsPTllRJzH)Kvk{b2Jw>D1^TFntaWgTe#rg%?{kaD+~tKY#r^ z=CgAwC!hJz^Wsj-j)H~tpEFd%XvSrJgYinos_V&i;^>=ms`hLHDDc6=&r!q{W+7Dk` z*?#z8g2cB^Pfy>v6~)irZ&&+k$BrF4w(NbvW_i_prpIh%(G?ZV%jS4^*1z6U{M<#T zGeSx5p#8=ffAlAMEoD)h1|lYU-M5+IvBXHSMq{EE=ggiz`l=j1mhbp~)_GOh<+^eM zp2LMQ%P$)69L_YEUGhb!%_3mqem=E zjG{bzT>)m>1Wq}v-6p2Ag~@1}fw6ILVSOP8Y}D$Lke22)hiqe5<~!RgVSvu69LR*SG#(d#~79Osco-asCwRpZibp3LTv$YsALZ z%ow|0)I6a=bMna+{^++YN0SUi6E;ThurcSE?=xL_Zh`whgC}$C^+h^ck~VrMHA;W* zSDNa@!L+N>i-YM`-@omF``c7iIWqr7iSAtT_1fmI^#w0i?X>?|Ge>0lgb%;I%0~Tt z#lO?QMZNry1@GY(7JZK^Za2-J?NsRdYQL!V(biSJrt0sw=<;}b{L&qa?RQg$;r;n&OC>;w6waqyEkv% zyfY>*F!16`&~hKk$IqT^OFb=?pZ`AWx67stp%a8Nov-{+T(M$BgEZ5`j^;nMR{CPI zCbTYyTfO4v0*>7FKceE^OLNqORheBf#MtKgbsU{Kb*h<}*(|x!(E-;wMZ$EJ%<6J0 zNm>`PlZm0?<0IFcoHu7@nkb}>4yi73R5Z~klyUhDSKREW5p)< zEy>!qbMiGO$Gr~jC0+@tky|Y%Dha%2(&TncG88_mBRGpqEmk=^a3yo<+SUMpS7xE- zWg7!z`S|!iXE+}`7$6_Fvq)7@@!`>KanRgvV&dOlU!{%H&VW{we0lk~kNr`B#r@jv zcei9-4qF%V^T|nJh69USyB955q@$}lwX$;c$wj+BJ+GDCn>V(KOnNLex%I8lCgm-x z#?H>5LA-!z|JSq%b*8yDIWRE1u;3G^(_6`$nyT3I;}zF6CnvSl>sBw$@rv+LROOhr z%f}%=ex7ytyB8N12QT+)l|Qj$$&$6v+kgH3eSWt2`zI$StEN3!-fr?pP#>d`*wDfj!0`vfWvoH7SV={t#7@H*9U26 zb9Z-jF)(afwICr(!fhPWL682I=1@5<`nx4sKGwg&~jw3s(#W@J$4 z(IkUqKYvzQS+B0-ac<|6wJOnIc<}P_@{b=s#^`O|u%Y0@1jV=S0>Z<;|NVa7zWQ5E zQBe{1*;`q#r;h=IX;^5lH2 z7mc4@y*el-rxP-VlYzk@Gc+_Jl~Ao>`TDYWjhCU-NgHpaD6-bB zi(0m3P0pz)ntQ9i-`icje$`Y(C&>w01squ=N~{(<;q9GO${idO#K16b-n_XvLE5Sv z!G?y;zrPf2+W7PLxw*OO{*(MS&O8%nXXk8V(^gS&BRb;3>+Cg?r-X(tPf2BDxFaj8 z+td4TTa4t+n0beuy4~5SJPFiESl%abalw?;QxxXys|XB;%1X|OIosU$^XEvOV{;6Xt8Uz6 zWT@L)pr~UsDJMBAL*l~v`vUy@@#WvQ#c^ppe)NcmVZzCj^>KTfc6v%XpK_W~w9N8^ z_Trm24z1JX{=Zpl(aZ4b4vXCW`X4;{b=t&}DM2eubamJ6+I8#DAtzV&joY?~aj+ac zdh~sq)ym7w(^dR-9=;jae|IGx)x7I!RR+4%3n!^eSvPgbl` zRr#|ksnxEXnPI}|r_YA{^H}u6@lwd zpSHih?{8t*w*vq3%&Ws38Ech>LaY)`QDZ{FN& zmcMRA{~UWc(XY3s$M3Vxm@t2P`H{KS^Uj@Pn)u@3=5%f~pNKyX+t<$9an13ZWYmmf z%8@s;R?Mk&zEV@{zEXCV)T=42A~7+`nX0O)X2~^OQ&^g{$kk;!lT>}n7463^QQRwy zYioXfdb&1xJ2-u1S;Qpoo-@g&_G8k$^Y;6-v>$J{=~h)$Wo4DkFPmgM^WWcJj0`S9 zC%3k=bj_M|X_eL|)uhgr2`5>SHcnBvBO~+f?QM1jkJBf2&Yk6zwSn!3ntJ`l?C`Y? z4#z)QCLjCq{^sX@lhvQ+-HZ9Q|9@<6|NU(h8|%6Y#e$k5s$X1KIYYu$cCP5<<+G(F z|5iP8$|)*(^!d5+?{7~({#*^Z!{ogj!-2C06)WyoOpN#w9&%+WJAa;v>RWE!*5^tB zopMW-KNl76e|5k9>b~0k&6^n+%H{0$&6{D7`63`yih&_$)23vtMzQ#71_kX^rJA^)ltsTc_=unVFfB zqci)gpO247r;C}HS@E+ohK7bOZ!AtpP3@I3U6uU%O^rfi#{wper#+u!>}n$PreA*f zWumhCs)q$<%8t4?@G4LA;;;RDcE!KHhCJR+i_+J`?|=U1C*$6#m&F!N8+YtkcI?iH z75;n87sTlG?^$e`2HYu?%#Ico+31DBU~zKR+X z!?gM@fhVSDUfBHQ^2X%+<;(PL?yY{Bzklw{4F|8@i&L9?P)q9gy4c;He|-Gi%=Wdo z^ls6giZhGdh1+=!8mzg>R_gR{ny@;H(#4JM_eno`$jGn&I!4_1c+HcK`|N9WEL)fN zb&e&sy6+)_oHGrKORru{YisMhzu?)r(AzH-+&IM7ay04Xix*qo{PLJ}C~B)1XkP88 zwzELXvP)t?9%8G0I)8{iw%_mV8r4Z2lQwS(+O+A1)59|j3>DRHXDMVSPPVD~qM@Xu zq^>gsTYhS1ev6a6o~{L;wG{!7b3T(dbwPhUsJ$IHvh*H_jc;lPT(#a7utVN<6m zzP$5YwPl;b@~lR#RabR=yu7RmAF(hbTv-t)yKl-hmA4zjP*!x=*%hR7P1hFbp1-%| z=b{xW`X^8J(9v7Hu58;@)3y40XM_d_6n@uZXgHjvT_50{oy~Ay(aFgR)mmP>d{HAW z^GC(pT+Q%f_sZb2vsS*-nB<{%Z*OgCNDGgiVGNj9G`ogoHKTyKuejiVYCcx_5a=kmjUTsow$}R!6^dAAdf-zjv-dN=Iew z&LvHkF1`Q%=W|t((#>Tn7A4(&bd)zvL!^7Xs!GhhdimuS(|&;Jf9K`@triwM{Py

FKUrbxT}OaQ2)#yQ{zV7C+BgsoY|f3aT7uEm^ea(e3>Gpu;b|@7TGMSKhAX zNQdCOdGj_%O#CFkQuyu7&9}F=PrtstbDmYHmW|Dx^4bP3<|jp!US6l3ot+K3jP7>+ z{>-_YflG^1Q*>Pd7&VxuoL5?5rXdtBx)wL>Z7#v)Edp(pg6AW5(Us~u*KVARp_4+@PCfS~zzFDGYQ5P3O z!_tj~)_%TT0!$lcoK3kkYqt5#1#Rb_bGP2r(T&VcUy$ign3}5EuRi^>?Ao>4+gn}~ zTljPyxp_?O_f_pik0qzmw%=(uoOBd2sBdQQp(ZEi$J%IyhJChGf3_qruC#1T+8Cp^ z-PAbS@-}Y^PqRqZ(bM1GY}~#fLR#X+Hg9LoQ>RWna4XLAXgs8qwff$Y6Hy!{yWT9V zjP$s7dG~|%1+@oezwceL#5um+ciCaDntwHiCo1cAb(x);%gwMuO8)=Rb+I4P-ripy z|9_tC?#QUBojXikboFd(<5zzC*wdY3Vnj?xprT?&h+g^0cX!vHI>o@C-`&mp>-+qd z@7Eu@{MO(~WK!H*p)aup1_lzAMJb-1Cr?aNmba-m@c84l+}qcj>}G)OfU_=FQ&VGN zICJI<=swo{_4PkQ`j1C%O7YCe(P20MxgzB6@9*a3=I<8@OkeL28hUk>X*Q@Ub~`xt zZMn5%()(yT_1Js3IZcIL2_v0aZh53dJ8zc;qTB`a^XRJ3=+`oin)*+)$ zpZ49@x_ZHulAG@GZ~HrTOr2y@e~6`)@A%DQ&+?+8s`B0`m~XdVAG34flrKMzbed0^ z#O3AvnuRGyY?h>y)T>uv*RFM`2wC-gc2$~D7~&rbD)E^9R< zHeyHBIho7Prpre1w6#v0=%DiJUB7)@+?EyL+w*wurHVBlOfxEu+pD#9)upYQnyfiz zzWMa`cXXe8`Ic>R_7w&U4jN+KOM+ZiD|a7!k(WAk!ocekM90~bNq$SF+QfNRmDVF4vq`L>H{lS>lxQu$5aJbmGHu|M`=u zx8>bkwT<;%*&frrYuEO@dv{$}xK>Oj;eo{*r!~Ahe^S1@2+{jqmZjzM!pKat*oqT?Ck9Q(&qQBfB&%mf8B?#*C)5}y?y&&!nC=y+Nw);Y`S#pOip};#+8av zrp74>b`mxfA$yPOFeJ=ao*t3%>hkyZvYH+9^2{es{vNkuL+-^zY?J)G1SHxj zt*p;~`3`DKem*(be!fj+Vh+#!>61=*v1_lM{QA0cwPvr|VS}FNh=`MqFBaCor>Aax zez`O*N73@3V5iI3pA#p|azE*_Bv!yR^2XA&ttz)H^sjJiWpWJ7&0U{&*CTjgfR0`D zXEg?fUFEMfL~yJ7dOa-K%*BdZ{V;AJ(Y*eZ{hcA}D! zl0rgBsi~#U&PdLv`}^wZYHo48JNxVZuZ!KCAo6I6X7Im%zu&*#|KHBq`uFSg`}6eP zzc_FIpU1qZsVOSMtv2MN-sD;@P%n$|R_>%AQ>hZAy|ur~a&mG~QdD}5tNr|RTA!JT z>55rT*&fpzwc@kO=Zj67W>@`Fh#?^>V@K`Br2ln)4S2Zc8Y)|AYrlGUcw+7Eef9+p z=B1uClea&YW)yyIj^(-Q;>#~S^Pf@h{Nv*-b7X?VW=Tl=`10oG`MuS3Ul*3_-CaIE zw4OEQ?c4tmTVEYdJ}zfh{q4g|V+Mv^x6hwHMcLKv+TQKG`u(isn?G-rwdRZ4Rr2uK z+E25j>S|-!rgxt%(AVcV9Cmiz*(4)>7x(!+osIsHQqt?^8K<9{VVGhkVW0nguXflD zOA8IJ7m9Ln%6s;F^7fjNX^?(qhIPMu{I2r%g=OCsot)hJ<;xKvPoJcul$beF6#o5r zHapPHZuYaY+%N9`-@gC9A84J@nwGf*H{NI}yG;-aWY=CDcXyX+dTQnQx!m7--rP1{ zaPPX?ZN+eppEY&Gn*u7!*Xm{KhFm%P>PudB!?X84p4??z{%V!V)fMYjuNLK~x^YwT z)vH6c{PS&h&jbxUzi&HiW2>KcPv+O_^(P}EH=E~`6cr7E>;tJUR$jnh->(;F-jsh%cl`}2)T zYM!2&dU~2}@Z-wumnXbV%C0Uw-UnX4Y`kK=69Z^AAVN^>ggyqxdct7k6uEO{@J9DB53cq~_)& zFb(XHbPbCO&e>0HG&0}Rbt_etTDTm5y=yyT@&W=b=xw*;J)>Ts^ zbNh+|24^DT_si<%%n4mu{{3Cy{{QF1xq82RnKk>+Lz5`(#FqDcdb8L1?3!Mdykzl)(zg#+@2G3LWI1NlckL}A31Yo&myJh;?MK_=R6P-TQ|q@^M$9UD_va;0%yeN zO+We6qW9Y2b9vVq_4*YChBG|8;pgW3JpAXU zuUyBk*GHzbW|ilr78Dgt@>;q?SabSmT@8&DK`S!mOrGp4z|!lsyZZaPO{u3#N=llV zo4>!mFE1-=Tlc5JYA)aWrv(<5m;29$oKFk7TXMsO4btX$GL}V0jvigQYgd$pNT0lY z-JupvMMXtv^Sqw!?&#fRYxnK5Yd@T+xZTv$(QnBW6-DL8FRreR7T3$!8Mp1#gwszw zXM)-sd|yu*Y?*#+RsZRyOblM$AYf{!#P#seBctSFJ+0j0Sx;Vwe0-m3DAC(3S!Q>J zhgbA5|84iRaN!2ZTl!GIX~x?N%@~2Q#Yq=oaw`z{CK{Y zPJ~hEt4Hy5H-+WwZX7<0ycr>7RVs(#SXgt;|D{Wo?D>AL z+S1Z8dVAj1O`D3!%h?&eyu5t;>ea7DI)$g}$4~Q6sj-{yxBUON+xd2Oc7cI`8#ive zdGjV8A0Ox_k8f{o-rHNfch8;z8F}M09|wm6GmX=)t%=;6dwbj7>hH^zEF4G|ZppZ~6@0-x8v_R)-@Sbuet)&e zKi}{Fe`w2(ckkXMAMabbmlr(edAxAP!X5I*3pY6?pZJ?{(O_lBuUA)BFIuD&k#OtC zkt0{GTsd`WRr>jPR$l|9r5ZQgY!F!G*1T+q+QZL33l_I_eS0!vqE3#`&KSN_#pZ(( zJXB6ZaWHHW)8cJQ{H*SCAahHtpx|9y-SG1>jW?9%^BjIw`{CercD_9oe{UVVHqCQ+ zvP9X3OG`i8-tJyiZ7j*d;82>I8|ByMqo%E;Ki?+j2Em;aLJ>wCO5T%hG0pXx?zQw* zhOUfBhCuVd8@tQj-`iXLaNhg<|L@(qC&!SGm9?wz@i9^DFcEQaW`;R)=a#>{#oEjh zzBWo!Na)jpgUspa>0;eer%yM}yrdEzAMft&e#0{FVwdy4Cr`#l^EMAC|j`zTae~rx75qGbZku&nFerfS}08(CE{TKX+?uZ;cf* z+a?wj85tRPfNR#AIXtNg+uM(SdV1Q_R))9XPUSA05MjTRz}3>`c|U&rGRvDVaiZhD zm~BxD7F}hVEA+|sX@SMnt5;)pIkYZY(;BtbDe>1cR$+CmcXMpBuCypRmqh3=IM|0@ zKk@c<_}kml=S*eI-Z05W&Wz`pV3a}OBEIgeKiBWC+gsqcF+JXVyK3-qCI&kRn;X~8 zoVj=_O85B{&jbUBC@pSVTif*W^J?GRFqGJvyOFrIL`{@bi@_SqT z{r&SVFY}GB`BP*7yl1c->5OoHCW+V(P+M=UaMv z&u+=QeCY6Dei@5`H#asW9&X$F{a$tQ@xH4UFaG>~zuteo-QO1%l{Xo#ioT)pB=zFe zt83S-dw0oiiAvVKoyJy+&G#hf`0`ut?l?PJyK>K#Idh^K8J1s+dHXi^%$XAg40GJL zvF;`*;uTtc@z?h^D;qCgNL%n)@anr;ryQ1FHqX5!A|-VzW`=y<<7La1>3%towpm;+ z#v=dTo;h=5BpOnZk}hpd_g7X{{_*yhfzS59Eqga^{J6le+3KCm7U>k-HyT;9%hQ8Q zGc7G=S(UUqM@|-*)Y_rP^)-o`k%3!Ghl8J=oq>a;33U6%ix;NZ*Gz71x_U79!<#D& zi?#}se4JuDYm=Cf$d!p!{j1Y@zJ7JzlF^=MD6uauFH5@rSKwx6rav*6FQ3RAHaJ=( z^tRb=`R-jHPLwut z$({vKIf@LRON-;{|9;(;dt1srH9h_N%jNU)Zf;_=bD8Nedr70tn@iW6KFwSAY__aq zK;NTn*5+B~UT~x?G>hUE3~6ZE|2>M`M`qE=#h+W9A~ZT2mzOtPvdf?2u~OZ2*P5*X z)!M%lthlmnUEcTOQ8&Nc4+TTRjT<%us7=0k>z0+3)wg$dqr=0)RfHyP+7Nox{p{vo z(I#Mb1j!X%GtvQ{D&_W!@p$b9=(l<3tS&9$qnKAg8-Tw9ZH zYd_zcovKfYPOY=Hwg3T>d+S$xNjP;jO#M&nS#E|%?*Gky^fI-!37kB1u6I_d;wCXA zfeHA=&7!tTEa5tN;&wQ0wf5^L`6xVyVbPbd_}?X?n={i|YL z9`OEN@v)wb73u0(lj82~TDj%awyFhn6I!>OcItGQG=Kj8Pft((*&et?vYUaygHvh! zjvpaDXHM*^y&a_)=xUlAwDXR=UhJ>Wr>52}T=@8kgyQJoKjN~!#8(Ley@3E ze}mD)eOnjGc|>;1@vt_}DpR_8Z;CO8%W);4?rtqD6_$yop6cD)`#UsJlHoyWVWGqS zf5w0RGBQ}Mk9%uVZT9hJF+;=5g%8`#+XbH~EdBZK?}if|%C@$ykvslgS-Iha$9p+j z&}<3wQswViuU>J?JmcZ-|1sQ6Z@T>MvfRTHl?zS@O`rGg{N8HyegE%;9h6d@etNN- z-5q(sz?eu*28j!+um5_G5bW%H^jvGw$D7jTe#ee5FdXBNFi1VZ!N4GUT%AYq(lu?j z*cIw~w_J>hh=`bRzhlv&MPhN6)<&DFtE*?U6qb~{xwrTC+1ch>*Bx1{WMdcKel+X* zp4EGnxoL=P4;5RrBF!g0JiOff_?J6()_;HhJ}vQKT>Lc8ttXyFJ@32XAa+P-f$sh# zt0TFkEhd&_W-Ge!? z+S60fx}3{J$vHMQR#ukhaM+u>)ely$zat~Vz)-Z)X8)2S9{%}{E-XxGPrZ7zT+W`2 zf#>^|kTbjPFnA{?C)d{Q@I8^@zCP~n!xw=weYRDdo6|a3ZQ}RF)tsEteKH%341d-> z^(uEieqTTCPTi}JoT5+ai`@^i1vTBMdUQm7x*mAm;A=^VTzdL?+mejikB&aMSXuLL z(%s$3lP5oP&Ulz&I8*G{vAepu)*q6xBuuR)#_g}O)z!^C+9j%MzIc<)niVTDbpC(( z{5kqxY=Y6ubLYFEWqI|*haC0$wo8sZJLEW0{s;&k)ex3~J;`{Se~ZtRmgwQ99% zl3_-&k-F&B9z#=uACF(Jx4*eX^ZVD=AeU|}Sy^?4Cn{Yx#~>l~*!`<1il1g1Z(f}2#JY2nYfwv73+*C3T1O`XU9goNJ#zqaYf*% z4n|GKHTE?>DndkRcKp7xb7AeTFNH-+412V+Z&&@~R=k6W|n=H@;HZAvNmJw@oq zE$4Q>qeu4GXIwbnEhhQ$<;ka?o>qU0*;8FUXZ`yn3!NDmigtnqWj3BXna9U>TTFkO zi5VM%N_wj9zo+80e9PO;zjv5^Wu1egQo0oyZQGc ze($aMxoO?Hbyu&?nlZyc@Wq=qZ=O5>UHtUm!Gbe8Gqx7bouZIZR2k*xH#gvhop^R< zh*r+;^Q&KF9lJ4WX05oykDuS(PW?XsPjf?!s_O=R=On|8c%tc7#BXaXg|xw(DUJwlx+gT&i#9qr9Gc% zTxDhT+u0e?RljtJfuZyK{OO_L(KBaU;bhV8?iQ`Co-mJ3)>PbQPR3%FYrJyv_y4}L z^KoF{ymROF*L*yB(0ls1yL%)*zYv_#nx1#3A~1x7;YZHvYmR%XpYN;t`{AZ>c=hjx zU!T^9=rvy`&K?Wp*!r#LzE>NUHQCkr=jeLF$>=1b76JT*T)9PJi=csR^dG2nO5r`6%>nSbQq$yn zuWy&nnty-iZ1ey7YIo=N%VinFXYby+;VGxkpR97zY{g3F%R7!shQ8C!($ixXnd`Cp zmG8E0hf_{pBe=Kj%2Q;zxY6R~4F-l|d@?H{Rw?RQTN~Tg#|BI&@0Tm^=bB!vXC}rF z5D_+Q!bHQ&7Xf0EGt)qaV{mS+5}P)4t#yfq+T_ZeJ8BFnHW;<{)qT-;_3-e*+Fvq1 zUTN3zr7zq6|Ni#;W5>XWf7dU8VW$Axp}-hIDc54sLFCA81;9R2-$;$fu(j_fW6H7+Z@ynL&^IyI{pLw>TBQfIl zKHpXAB%MLStZFgd9y`82@vQXiy?J}%agGZiIf<)#chAzvX`jEg>%tzTCiC*Ir=3!C zmoNo233RyVws4l7P<+rA zKYx0ntJ67W`lx~We9W1pGk?3e?D6&LYHI1JH=`pujwCrH?wb7L&(G_>zcVw` z#cg@9KKuGR+sa9?5jQqB8!aodtFVz1X%Q6_Wni$i-MiF#x?a>4kCW5)SLZx@eSLkr z*`hUTe16KEIeS+6guT1F`?9X?(5%H<1(xOQnmK3Asi#~nN{t>{92UOYxpd>k`P+ACXPFur8a_Wizr3jE(wB%^{9n15HT=`+ z_B}dmckbM}Z{O4xE!MxkUtf`9Ux3H)kN5ZexwJfd{fc(}ACJr5zS8{3n7z^ALd3tPPoHkpbU1zE#*W56#U&+I zLL1$aR5P(dAN;tiF-mztelG4_{l8`LlcG-&a?!|Nb6+_1dKBn!9(|zrF*_ z$njVIW^-&S+G*2zx_3vhyUN50D{FsQ!$pgIYI?JeefaU`$r_zTk0)9A`T2<#-`!M@ zJ-#<4M(Oe6d^L6D$Qv`vW*%CQ@rEVrquE6Vt@6xBk3~MJllfXiI5mV?J zeUhrdiGr(dZYo-r+j;oEzq$5yL)_jn-N;Ss)7TYr~CzOA`AZc7S&o(+Ll$BwINy!WT&(Eb_zKoQTKF`ImivE2e$l&1bo__w*Q_#p}a_p}9{|`5(*DTx+`rbn8b6Vuu$6@*FV|Erj zIWe)FU;a?Lu(}@)AK$mYise(5cxERS78WwU{r>6c=}VU`nSEWedNp_G{N`rnnLNj4 z7~O2X^38eL=T}$P@JcfKi_3FV4(#e82y`Lo3(OM~lPP%k8MxxNDu>i@)FZ&oW(oGQUJ#U7hFfrQ+h- zbA6W!Uv^;MmZR(9w`4*0X{S%m)}?J$)Jnha8n9Vd(N+? zmJ}CoMDM+I>WauV%YAuwr&oVhOG(kWzqvgpIWuQZ!lNVi?(V)^So3mA=6wHIM;?Ff z{qp6+x)&DvN*^9t%PoGW9dy^dsKA8yphr2$j}vlz{ruPZ`Yx{fxirVje z7J{ykE_!leYxeaAhXs}0SXfzmv+i|nPxXkt|M&Oz=jY~fmoD&7$qH8IVOzXWd_}8} zH@}oiu6;_WQzGwtesd{%ZPgCv>H6VUuIS7)Tbti6x8Ud`<6ko8{!P_R3}1ix^iLYKeUF&-4 zHg%=s=PyUQUHAW)6duCDFvBY8NcG=eOP@cJ>+ibt=X(6~_xJ60WKAutby{%!>C^f7 z_y6sMQAzzD$N+##lMsI&nQWX>w3xUT>5oZ{{NdlK1#H0jVf~6`21NG zf84I}_aQz#i_UB|FuNxrmUeSR#pB0s-YhsfTRSU@fnn$R`2X+k>^wWa-tTy!YWA@^ z-rlFRwTv!Y{abppOE4}@qRm&z>dR?K<8Ocee*b@N{{MIV_TO6DHqG>bp9D5rLgG#2 zzM2iYs{>tG9R>bd*W0aL@W3^2;v@0DVf; zu+Gcd`|zPdaeFERg@u_JEI`LvCLceMelurV*~vraM52H6rHG3iTegJf>xY7OFFbA9 z63d=FD|_}#_I7UiBiYmS>1k>IzFv=4_nXrYY^$!m{K10<3l=PRc5Cv#`2oM5J_CVm znQco}ug*?SPft!x&d%ok@0=4NwtPX_m2b{z2Oe5PL_0^Gc>MXhyUUd;SD%WC^D{4) zbo_SRD-nhVzaJz7Gc%`po!e4nG0~;yzuGx&vmd^H|NQOUcbpoE zlka+YcJ3%%zG_?9toiZHCSSTbRD@jP*Tn0`1blgEZfe5w;(C5Zd;9v`yIVIF9{1~* zxK)55AUN1rpk?C3iL2~FoE~n?zOEO)ujlcP9!cXrfBxLsd&OPg$lB=bN=izrR;>c< z<|um;QD)3%sWh?U_+v>)$%B=b*6;r(B_`%(mz|X*B_LpsazY?JKAxMKdu?>pl3TT& z&3~lD!-K=k>pnd2OQ^YQrSdj*%ej*$EejtV`S|#FtCJ$bfjf6>G&D3UEN1u!u90Bz z=yG_v*WqP})s*hq-{0n1m1?bI=C!V_WY~K5ltqr2=5suUUM+|@DS33BsQ0D&wSOwk z%GbB7jmynUQWEIC>9Z`i`?QJB%95`Knxunpr-(?kZ<%iA=kqYUtLj=94G=*|XR8URj^k+EurN<>Y41(NX0vo4hmY z+nbO3_C48LE@y8iCAstG=ksTKd-`H`Z+mz`km10=(A94HWn1qTsZR(?UMy{K%Kg}J z(A@UJC7!)komM|T`uOwyd;9<2-(m6NLzvg8H-U@m=Ge>o-SyJazAj@UVUl;J!2kSm z{kSu~e?AXSO)WmwbMr_2w{zBOI+yT1m@L=T(6FMwBT&%QRfmJCi}h$xgx`U6%QfaS ziB`XVYU8@0c11d)|Au+2wxYHof*}*s9t1AszQn20c=yfs<0qt6E>u(eTwPI-roMUe z=Gk}8?)kh;)%%6Xtrw2X|M2d~ef<8aiV7Aswj_yTX`9V+ZWvU4O8Ino!GZNMtea*@b&w8ir+Ld{K-y^whohH+}3$%@mFCRaC{PykHhaWO9 zh=__V_n)7al@%2iSNH47%P(I_GPtAkqJoM_OG|Te-^yLh$j)|tdw+&u@+Ht9fUhxRu_43u)U;qBP*4Jlf#I-)& z&ctk+cUOty(6vL0j%kHjPMzW(tf&)wFA(HB(iJKgjNxVYp>UvAfrqfSB_e~OvK z>ZBHWIKkEe?D=)xe%IaI{YGYdJslAx9~Lxj+xGt5T_3C3qaPn1G&FqN#?#pLa)Y6! z$jQw&wYs{xii&RQ$0QifOlIeAy^VWwy81vt|FI;GLxLv-m>=s~sXm`9@zg$j&LD`1~E4-&O zFfb-2zPGQ<3U;np^DA+3LGHt=tHVo6OWk{=L?tA4WL#8YW@e6$k5`+#GwEp8g9i@^ zA0L}*S=@GI_C%+Nd-m+<>grmw=+U>gw{PZryRk7jvDtsVoo)5EoNsS#vN*oExA*sL z-_l=OX3mt{8FfEHeZjO74VfFK1nr6A~iAprD}e;P3D6ce7h9 zC+bZ|ZsqKa6cmKw%MkJ2@$tG*RX$m<+?C{FJ>A2wpTFO4FD&$4 z<|AmBe&z%tGtZW-f1RC#jE&WK<@L^|wg3Nqznfp)KxTeU+P2(-OgDBuzOwSYeJxjC zXJpZj4IffpE?RWSJ$m=Ct*10KG<0-L&5g1y+nfpVGVbwMnjyPCR6#%R{k^@7J>9KI z=1RG3+YF2}PuiPY@$i5DYO*>*L(iT)Yu3j%FIv3N&^B`hA47+aPfWoB2hq0J8(T8} zOi&czQq|HtS$AEpKYDwcq|~A7?0@e1o?nt&(43Z=8wgV0}GCQJ)^0S@#(v|(bTKA zoH)Ph_Vo08`TpJ9$f!xKP*PGdGBVQ9(edEHgA5H9E?l^B<;uR=--o7gi|MRbwQ5(v z!$UtlKGqcR3JICAckkZz_Tw884==dFt}Afq^5yk$dru{8EPH$F>GS8}^78X1O-jni z(MeKYa$@c zdGcg&-KZl;8~5zlb8k+}!{-kL zryH4{M@4Uz;5oiB_^-1QpKiR~n!VzNPkC~3Qj>~)KRsIEh9sT)Y;T$J{G-_ zH#R??oP2zboXw8UpHdw&GhItrLLSHM)#>exVo+sS{PmHV&78L+AchtoEGp>c73+ zGUnaP($^l{0YBdTm@+R;x##M=)6+eREBU5NPn^N=IkoeK=i+@AB`Yg0%zb}nZ*`}n zq!$;L|GmAB3wHW#+h*$XP}+9=rmn8y@9zXzKK^)ndr>ocbw7Zu-{0 zaIgHnN@sy1b3Q&U*NdAoV`k;so13S+dGjpxZ5s2^cfbBzR_RSYKW}fvN2Y1=HWdXA z54FnKR(*MS`T5Pw>5-9@TNGJMa^%rsu&nvs&C(trH-mzTmKS5Msg_U^9t z!hmygER8cSsqnQ63kos@S9IPuIoGuGxBm;eHD&(%^{ZCh zTIxN$r-$dox$<{+b{;w6qN=)7-G5$zg^arTaw$2x-g)7Xk)ZWd98DE}eiQ}<2KLL@ z#_g};E$zQ$zwWeM&5sER9hSw<-rU(~Y-srL*4AuIP0h9ceLJL|L~}@5CLH+up@4fO zm-v;54e$L0eRW%0%Ok=1uA7BO=Mi<`@Z%q@{&VS2{tT>jD~R^{v73otZ@ z8mH;ZG+OGmBf+Y7+r5JipHEay{JiUp2ce=3J$_^r$Ht z=dRUDymfebduye|ugClM$A?axF!ABn2aR#x-%a11=X>f*jhNW6heeDGD=jS-Zrr%} zc;8k-)7I+Ly1!KqA38HIOh3=h!QCtro0O);(4f$9_VcrY(`rjgG(^0z{Q5fPc07`n z6%hNjbVI?_HIYYCQ|nn-5-jd*Eq?x%Tl^MxBu`gGoaW7&A2TzJ-Fq)xzw)K(gMx`! zn5Sdn+P!-}Z^?YpZMuEKgwxNN8Ql0J7<`R(mAzZDa^1hB8#a{HrQgtHXb6wodF!xq zwDaP9@vUhL*Oz+CezK&N~ea3>p_z)zp|Of^+ZjM1OmGd%DxY zCCip|-S7+zz4{t7Hts!LPgX@_-P^afw_93T+W-HvdDpHg2ZsaG_2a|VMuo=2{J9=q zpD1x{YxecGx3}NEecSx&o}i$hM~@$;pPRFC{rdM$pNdLJty;2VOWE6~^z`TN%|r9_ zrk`eI=#jB3y1gy8#Awpm&5GYwvvS3X6^S`)yIC1NRP4FBIy^f&JOBQ^y*J~1 ze>c}~D{gfbRJtX+LRX}aNlH%c->0Xi7c5vXT|fTW)2ECKT-+dV`0(NNvAd5YZS?W+ z@mZbu(0b>&l5d+FtZxf$n0Y3#t!>d35yPiFOtb$!e|AU&Dkd0{|{Xn*ga)oWBZ7VV!muOQW{?As|}^+$H~a^@)>_OY=StFsk@QUWij z9Jjpb6TrAIK;uJHXJE93qj7)PKCUIdR@|)QzUlMqS>@*i;qxXnHSJ!#I&XVkY)Hrr zAGIl86eKGvMQiLB6zp_!A2u@2jNiX*mT9-+^8GzMI;&S4D3FQrnjtBvJHtZo-=Wgr z;NY+TfxW_PbNS-k#Q*-C-`@UQR5bPJ^Z9#9vwN4Tzm$#*h~i=h2oJmW{KbqnuV+@>Os^U^bMgOeQa<#OaKGQf^FWzr=rSY>*Pfg3p4kdalT%vwFDR-v5 z{H=D46DR*(S;}3hBydAF(zCAlTdtvyz zNs}h+-o1O>y1eOnu{UzeKwgrR1WjW!{P^*sm0LV5Ep1)Y)~OP&dz{S&UtC`=&+y>U(eCr}?a!Y+efrcXtIAJLW|?MlG;P?r zb?N1o7o2=zV{7m4t37%0WM*dO`+Iw58>jod6|Y+SJTxrq*z?b#qN1@eF-b{Dmo8mO zN&?+Kto-Mwqo=>0U*73yx);ufvMpBkXJOi{+UwEX)up7Y{Q1+TCEj}*YPba_FQC>)+qs z^X+Q6SWl!WXD2Oeu|C<&)aw-*8Z_xxcubaGAJc5z9LHae4!56_Ha}MTZ_V|+zwO+Q z|7zN}UGIZtQ%lmYO55s{~ z@pX+^R=K>qxxBp{M~-mG`LC}oH&$N!{NY3A>AJ}mG7P?4`_{_+JTlr{RdsJy7uWyy z_Luk7?wmT6$EtR3GxJWKYq7p(1Kic^bk{D~6EnqcjgODi*Iv0Bo0rRUm%h7aTdb_y zEWh?sL1Tjg$0ymlFDhSVXT2(UTV-r;q3Xtlz+=Y_#R{@CKD21|Yrk17v)oVh`qc=Y zV;}1NHZ58lTwHYG#JLcysZI-3Qd32zPQ949<#AnIJQw$Ie%V`H`7A5;Wv6f4aPyjM zUtgbdJKxmFlbIRTL~Z3-^>WKr5KvZ9($doU^5x5!GiT=6*V~o62w-at3<_dmxUsMH z_nyknixw@4+gl|nCzqF%b?Rx6B+s#5Utf!^(rwvY{yr}wLqc9YJ}5}ZOFb+stgo-{ z{r&y?e0*`6QaV2<>@InEX}P~V!+`^h%uk;@IXBljJuffs!{XB#eUDijCtP26@$;qY z*RNl^xU=%}vaMUct_WP*vRjFR>97G?b6`ja%kEeA_uK#JIGV&OZwDIdF*Oa{Yo+va z`)n=El`B?s_$>$RSUWvk|3dwy%J{B`3CApj7d>X&+Uh&E;neBV{&OrqyZ0s9SXo*3 zR(@8~1A%oLHcZG7mIQ&ye}5_&9Img6W#^H&u)qF)BQyJ}moH0SU-SLqVSMRp%yyZZ zR~B`!HYwz>vHkn}?QLV~>0PT=3bF`Jp0sGq3XL@)wUUx2pMGYSmY5T{ecjznH;iAD z$Q)n#!f(aL31&98r>EvB3USvv#3UseU$}PR#mc3o=WoqAo02!n{QR{;O+Kd=uUhiN zplr?g%gaS;_U+pA=^88drhRpPr_AGH2nY`Q_jmjK1>ZkgRsZfda%B1Y`_VIJ$dnav zb@hqy9a(fB&Dw8n)rH%$4?oq?*66qdYDfM3`sLb?_@Nt6EjVW*5+}4j@mXbbOQu6Q4 z%Fj)m!cQY~Ec`z0Sr#32ukx!_ct+ZS+iPbh_R9%&b~dkK<<}OTT{>X3e?<+Idp;CW5PVQ{LTO9UUFZ{pUwTM#|dNSZHW?SO|1CH8m|>{P(<%X=D)wc=YQ<{<^BEov$IUw z`DACAWKP<=dGo}Hf(!~;TCd*S-Mw??&i#Kro&NavxT}r6ukYEjv(48>Y)m@X#JV~C zyqs;734?;F>eFv;Z)<95rlqCT|NHs8iIw}wlP3=>PJW(cSNm(Ob$L;7@ky!J*jQ)h z!yO$Qm%d#+bt>!5jzZtRZb!Iw4qK>;8v@|t2 z_kFjo`LW3E2entuH5X)&r&-pGh|f|_oO*yizi>dc=7Z1_vN!~Z>K)< ziD>3#vGboV_vPC&PD!gvnOhdt{*oywx^(?Y&ZUaDnjae;zPZ^nNodm~uchCvUc9+8 zW8%z-6JNx#wzOKxTDLviv2WS4sOoB?gMrzG>Hq(k2L>{;^VNKK;HX^|EwAj>bK=yg zStgm6b{0Rs^-hDuQAAw)a)ybjV2x$e#>v;$$Df~XfB)>+vqz7zUVC8i?(yT|4-XvU zO0QTjFwFInw<>wD(7FA~>!J@C2{!t1dv0tJ(`<`slI<5H85zg}Ra)yTY@9sYHt?U!% z*p_hX+S-Yoo!;WQw|182uPz5wr4uGhm^g9bym@~A#J3&Je;~Md%N8Ghe|84YCBbKB znXa`n>s)$wnI1=z!oTwW{}kor1NQp0Kiu(rmg$qCojVK)i?nofY^uJjXl`zB!ZqSI?eZ8|$Vn*wWH+W}a=dr>CZh%8?T% zPF%cLc)U+GNwV{Yxb%@!ms@$S7BBl{vOP?FYwhC9kXK65vu{2==BlD5Hf`Fa6(NV^ zZ0jyv$Z#v*=4v&{J29dB|L^!a&(F_{-_OKQm6>_)inH(AtUnhPUYxG~*y5hh#+{5T zER47Ac5GSuUXQEw($1rqcV=DW>_4u|(X{0u3&VwsEw0^SXXn{kD=977uwjDRVo52f ze_t;9pWVQ)FhJw+vEJ(M?_y(OV$#yo7#4)DkNfrO*Vzd#j`hpS%gXjXuJAsR#=`XR zY{!<|lIy>I{n{Gdx3A_Wlk1T$U%q5sUUqf+`ZH1!y(ac~+*%l*aqjZ6nbMLw=h@8s z_LQ69K+%H(*S4l*$z50x-BrUqd&w{7i5`qbO08Qq-`k|>wj{_g_kIRP_d$c~r4pw~ zq;eC^&ttCsmXl~-dQ2?aR72##JGUio%XXBhnVN<=IxZ}0GZgE-s*)SWd;z?NoiC@* ziK%s4WU7Ng3RlJj*Zkw08KApNN?)7h+}O}I`{XPWkESDs%8u;_U+?bTUv+QaUrpP; z8zu+e_6v7mUZy`u>HY4sj@C^-8-iDcZ0v4*B!2v;T+y;*2Z1BK_5Wjwei-z;id>$* zrALV`H}Uw6-5gCj3LhVvr0Sgz;@_l@l$11O>eSnpZ%8*fbWGHeI2rlr+Rh2v1TN-j zoI061)BgXdYir+%g)B>)p(8e7mGwf&eXW~1RXSQ9?O%OFRm{t~u2@j;sCkr|VEeWW z$#-H|Sy|`JoA>hNOHonL=jY}=F0c?36s-OAW#z z%1&DZFGRb88a=j4lKLx@+*B0j?)|Z~Rpo7s=#q?ut|C{aOnDX=&CKAGl-zD%)mK*P zd;aWP%PJG^sx{|tPbr&x`GL-=WQmEZR<1ld+nk?4KuE|a<-~-Gi`|zmUw(d$<>ur$ zb6c8~CMu~gzKCAY;j>AVP4kn6^A^FN=g}QoT9uk=xETfJZ`;tUxN+Y_At&qA@1=Zq zsfq?@h@_{d7Z(?YhhN{a#e|_@>C&ZFQmZnoZ-v-|O_`j%`kYzvSxBcJ1u0; z5)II}|BvBE;^*V`YR!yGyrMgfI0-5WhbU|mU{Dd;#wPSaTDR`j^$roxNpRn6VrD1h z=m<$W z>J;?xQQ_-p74AwuIart&86^6eS7{aYFa0`EDK~NQ5vRy&VwW;ZG(@`t&88`A444+q zA&|?_%5>{4*M8+pZo#h?#j<%lbX+_{e6NbVdj0yb#kz$H4J$q*#Ky+j+S%G%oQ z&AYp6>$?LfVLB`fGFBxoykq7xoL6TM5NL5wIKbJYz!4#KfJvl_g^7tFMnQnX^Z+Xp zQ=Qkg|JSA{a4@ac<1X0a#vG%du&P?%!4Ll5huAFst#vG2m>k_0xNy_ND~4TR3g)hR zN@x10%|4s9G2+nwuV22f@km^_rhX&JMA@-5r>Vz-QDCv)$_XM|@nt(48p8LpKgO6qt*Uz}r=!JFIl3#$Z=)>+Lxx8R~g$*=GG<;`c7 zSWP~tz$(rZWPH&7T(h-||a*A+p!4$q_x-M5dqcgZYm4ifz*c3TyE{m1D zQc0@*VLfm9^zY~G|8rd5d^1Nw>D8-Od~!AwCnhMqdi83pU6f?vyE{7{$uTiqWSO<( z#+1~J0?2$F21rZ@qR@S|i|imTjk-+n*f{VadFGF@nO^N*`R6>T&D8dhLLM zk6@7E&CJXk92_hxES#MD_}<=X^RGUskmlJh!92N#}FmN;}a4-dETzJr;z|rKSxQbDP%fv#F zqluwm#lKT0WLM|CsR$R<{^|EzrY^<*C+Dn%%&6pwaa2h8V*xA`JFv!Zv zCMSd1$o+D*QStHdQ+c$5frlicVsT9hq`}gPP=jZ2z)qE@p9xzPx>J@CdqpAx6Z~y)M&A?Dx{CQ*Y zaliR?dlQ3eRK%17E-`UgDl2(J|6qPwE4m^iA?wyUcb5|kb#-+XeV;3A;1_n{lsgE8#TJAp|bj{?^qerh^oth&S z?HdyllarHES!v0jpaKF<4m2{SrKSD-^>wy&d7iP4k03|OlLn!X4vwi_uAEx?^uM0m z^ho03#03{KxVX4-C*EKGO=r)lRjVFaR@LTanPvEq22EH@WdQEBPcEn8R^BtZRG z$%(sNTwGY#*xX!PTH4!-E#_PRgEeb(^!3-@`m3C)$fT%il9}mwY-{Th<*7T4Db#J8 z;4&?rmBmS9fx!hay<83!ri)!AudaZGbGpU#&z(Na&B5{GalbuiwEWM{&)f6w@2mY? z_Vd%zb?er7d3hb$Jp&9F7*3r&y?D_gyP6*YA|fWaw@eJ@GBP!0$W9Pk+2PS7xRPb< zn$w#eNq|nM@91lpIPv1X+TRQe7N8xgHII+=rk|S=7#s}Vp?CB4ZTp%Z8_vm!iio)N zNIZP>=+X1%=BcNqSlw1o1$)3VTI2Ml29=gMg8L&ndOQR{hm60d0Bsk(zfYE7!qll- zb8nk*b9aZYkJF9cC&M5hFTcO&>8W06^R$!{lf~&NE?o{D+!~Ip9RV658-I&+#p|dz zCMklCAeQY+Jw5I3uh;7t8a8a$ur_+TmX=oD?QLhTUoZdh;UNQqy}f;P_3!NK>pH7v zaIh>|>%ghy(!x{JcEpJx<#!n9@LiTg&YP;g=VgOVDmvWG|NZ@aeg*?`^ZkX7k6l|E zJ<}(xtnAyhwb66u%(1Kcqfs^A!?VM|gWLC$?v&KXhBGgFkxg2>ss8`JeYL-t7|zYN zumAh2G~(rxCtn^MY@T2LPmdMOd_5WkT!?*9*^XJ4w<<05m`S|$q zwm3twXP1kXl0m$l!!4%{o%I5sn~s<+HcDERyjT~z8@xT-y-!B)--&zo{vGR;<`&mu zVE}bp=30Nhu+Z6HcWv#03C0{PTO1Y!yeJ9${BY~a2oHfHkd+maX3V&8rM6JU-^sINAs zovr%+Z}0l`@Av=zSN-Tn=hp1&ljKX^-YUJnuXeF}zg_985EqvtJBy#k?X7zH=uuF7 z6Bi4UqU(hW6Q08^nK!k*O_pfeTlx9f&(F_){`{Ghl_hPK^Ww@%VTJ{Z7Crj(^z_1o z3+w-WoxX41zZ)BqkM~F#=iJ!v>yqQ=pMQS*_;KOFh5CO#)z#FNebby{TU~ZzLn0R! zm+7nSuC70S{_NSahl`8rM$WeF+qbV=nYs9|TCYcHl#Ywr5>R4wIKlM#`uh6+|7weh zHtpX1dx2x~u^vg~KQDiLe7rE_#mkqH;^Nzrj&fC3|DLKH{_54MYcJR}IR!a1oBWn{ zuQ178s54>u^yg2XK7IT4?BkCoPAzn9zjf;t6GMc~w8loqj0_FX*oCU9>eEB5+zbrd z+}-i@f3Kc8)paE+N#fga`T9B5<$Y5?Q?fZZIch#L5;8JYtXt=|{Bm(ok&%(nlc!Hl zGABxiNl0*9-*#f}sne%jMLv5d2hIGZtKxXe2@?7SQc_Z{udi=^ntZ(PYFur)fkgK8 zb$d%+U)xdmn1R7O@6HU<>}yX?PtP`Sc3Z;HB62Xnz$NEqPGrO3hi~reEWWWJ@%j1r z%Y#;)I(2G8-HQtg)6&v5_$aa8rF|LYvkqS zlan7W^`7qL; zB{JjWhsVeJ!`H>E+C|2Kb|~!a(jDzc1FgP8?9&4 zcJJDy7qjET!De<&PR{UkF*7ZT)%uSY7ZzF;JYZ-`eD&(p>aev;;1P!7eX_G=&En+& zjl;9CvEAO5`?$cu%F60^zkGjJ*R4&d-p|j?on58$;Y(_ij*25Rb(K_@<=$ek&0QC> zGbkh^B!07Nx7gi1mBrWAL^3oSIN)Gyy?e2H|Dgbrl%|{|x-JST@)x(Z`c7pnkokZA z|G(`iCnt4vb;a&3ySuj$~jwVG$M$WY;eDwA8 z^<~SJJwHGH``53m3=RwXYOb0%fR3MPa9TLWuJ+f{)6@6=`NTc@Y~H0v2ao$sDq>6x zM*(r&QqCSQxN;>V(Nb4eckP}%ckbV>Khhy+ zW@eUrtmoy;&C44b8>dda+9zwhY4c`wez`rFm(_yysC?Je(&FOaxUnhq^bEt~GiT4f zefze!sOW)v^6|df4-XobE>&$yJl-!aZ;)_cSLy4Zpdc5URG~}T1A5#99`(C;HEn@h zt9OE<`1v_sU*EH*P6;jDxM|a;Z*Om}U9-l<*4EkC`SYhw3ARQ`t_mvim;Dm!QWBds zb?RKZ+9(BqH+OfJUs&L{VbhW&Dhdh;*4EYM=h;?&d($|Tjg4*JzI|C)Squ$JmZ&f= zv?bmI0|ti5%Aaz!RWoMH;E^;+`SRjoR8-WV{Hejq{pMPgdi~fDq7@n(eEGx)kHEl* zYu?=5y}iHR-^;6O`SRy;t;_R3?TT-2HG`MQ*i;0#y1E`Ip3ZXL>0@`Rh9l(G45Jg< za&OPNa_@r3^}>EYuBzdH8Jt=@pZok@1yj@NeaqA;1W`RcXQHFt{}?^Q>LiMbg?x$cqlBAp6KK{Q=>~suHRjt zW!ki91!k32bNAK${x)I4gzxX~FJHR!=dWLvUY4Y$rk0kLGB~)py5{BOZO^;AE$?nt zc6N4J+O-uv8ChAc-oBNUm;e9f=4M`LvoEi%t`7Wu^xRx)YinzEcJ|vhZY){8e7;TP zrZTm8RtF1Z`q(qM86BVnw}8@xmBH$p7;;ZCF*C=;#;W_yQn}Z-l|3`FLBWBymg&xt z0Ik)lRvGlfM@F_L+E3BeUcG9S!`qi>U;9e0A7-kw_%UCBg{3i3qV4hhe~&Ht9^W@J z#Z#J9v_Lk2(w+LL^+8UrF z+*X23uXAZxT1EK>9!@-{{9vk8p_HrW%~5x%a;eQ47uq1 zAR{}wU&?e4BVJ>IAQc8>(?Yz?@gOPQxT%l(y!mVu_=ES6CQrOZE}yNHQ(y6RyVh({SGavdu=^u(MReMOXmgoTByJI zqk!>NHxt8}wQCoO@vSr6vPiG*^i$A6|EsIRudk24zd5~s;zYra*iS$Iym|8``FP*c zXV1R9zaJkL7dPb|3lpOVcWP9$M-#LV73g*Hn;jAwdVOtldSc?khYu%Cn)K;vlz~Sg@er$%%<(g`SJ7DuADRH&Yhjb@%!s! z#l^#)OYrdUe0zIaJxR)0MT|*diI%9Lj4rq>-P;r@eeSBB!;(kt?l%tf=r|~wLJU3_Tbbw6wHNojS$P09p@pYv-y}TFnP9T)2>NkeQOb{eD}1_NSVf-1$n<9@8T6`1UsXRe!*PSC9KsxndexPS3phlx@MqjDnT( zbPObVq@G%7zrO8#XZkAN2cLf~dBnkFF1@yWmc}1<10G=x7Sn&%GnA)UZd74TJtVbV zZ_nYA*8ANS?mM=2NBH9Al@}K@`oCVS&ao)!p<{@)&A#Tx7Cy&RV^*v$oLkLlE_QGA zQ`;>m6IdLdJbSk7f3DlwdehjrxVp>Bd@tNKFfsY^6|@y~e;MfJjKYbNCug5`+{PNe zzfSbKfPj!=WaP{p?H5>F`u}+5b_r&(GfPfn0`lVnxNp| zqqZzAv!Qch8)bG~%)ESJqOyCR%u6sh`TVp0Tr1YSyeA(B2YD41Zrst{Fo9!MVq+hNgrewvFnVFe-wZLM|rl>2Ck&%&_?;6VsZscs6Ge>5(-ifLR zh5wVU+?XT>}>EXwUMZa$Z$73m9WN%7yt>lqvqMM8Ab@>K z{+i3D0(a=-aB`mJb^jrty1{+5&nFF$u7Y)O5C0#Uy{KO6PawB;bY0n@1wSL16*dNl zO{m!@z+jMZVS)YsKc6ox^`19x9xE&By?ggA+}?05IYX#H6yac6O;M zD_v3*>-zVbdq4NhJ?AHG6JS_4eRs}177iB9Ubn>;Gct^Hbr*z(F;CcdNKjV7Q16*p zsp;SA@%61)hpWGXwxI0Xxs#QheSO^CU2~niw#aO0xYxzv=3?>xn%`?*HC zVoJG+OpO!VE@-c2+!3>GM|(p?@e+?{kEHbU^2f(|D?dMDWGE^w22GA79qs!6@Avvp z_v>v7uJbubIo>+drg${XZa#lXjE>7h4?)?sRjafL`57_4Qr6 zcyX=aE;E+BWl@4=lTT*64M^#*5)|W>=Cfy(&y5TgxoV&!ntVc%Q;0kDt(rrL&4rMN zWe%S=vrRBPFL2YvYip}YS65f`_PnnjKT6uwSTH=exjCJWkMH@px!INHbJ=HUZng4= zzQMK9NaP8IpQb&I=Cn-U`jrTT0Xw2r+Iw3AK$4x0$kzrH0u!5D` zk_e4#?$}M7Q75w)zSvD?wQ7stC@d}ATln}GxQo3$Zf{XRfkow~l-F{LLNm^*zy8zA z{%rr>X%nX(U%h6}uk-e1d3k1edFvM~a=E#E&7wy~dZp{xSvl{k>~Q$-@AC2oGbGj= z+?Zx}?JLZK)R=)rGI@P`3O5Xb0qr>c< z1bkn`OaleKnbOp=6O5P0XngZn^2mIZn!nuHC4!guG{wR~Lrvd=^uAz7kWhQ4=%MGK zGllhtMX9juy}d7H%$RZD$agt8xz^U!z&h7Wi5M#l$lE^BV- z=~){WC(&p8vV`Hl^+%6#pP#p{{N9(dVA+~I*&iRRRCc?tB7}iq|E5pgiHV|p?y9Og zckE7o{+yZNQgQKdUdc=T;nVMQhe!TwU%&o(Gdn}Wy1CZIFH3lizci6zP*}bo;phAP z?&|7?r}f|8ch{(X-{iHrtxlN>irY6P&n~@ct+pteJJs0Hl~*}v$~id(lU@f8`z85` zLfom0UruE)loX~g8v5P6m3QmQ>-GDiA|fg(D(>vBw>L2<`S#}Kx^?TaudT_9IC?Q} z%k#-seM7f$9oDn2`f`4E`MUjoHYNQku@v)m5HQg)efRnKQun@;=g&`LZln1vHf2hJ zMV@&YkEXB?%f}~ot(z2ny?k{|^S$G#$~~95xFS~k)ogn#E@!8sqg(p-srcl3dzqD$ z!sb~Vd|33-Mh{mSb8O*-73&&qbIUvlxPC087tul;6P!r^jV>{P`G$tTf2{yyznCdiT5F2X%g z&Qol{*86MK8uj{g6s}J6kOm#0HE-TL_kOvnmo70eR8&=2Sy-^JvTnUA=(^lC-Rthv zD+V(4f4;rd(Ozv@R`w~!tkr2@;L3@eoqW5?#iven45s@^&7eKv2Nh?Ks(WWwF;zHs63 zzkfKI5)38gii!qr+N?Zz5}#glT56Kh;-JhBEz6&K{(f58J3G24?3zpPUcvOlFf$rWh17I`kcFdNtS0+TFqBy4WFZ?Q9`Nuk`=2pB_#$ zoocw~@xi2x35==+Q&>~pmaq0)FjX+pL8ylNb{-EOAD?;ty^?|g28Nc_*0b~N|DTws zoYb#ETcz@7Z{~K3f4_2xi-TWZLwD)rj88dc z3<*;T0X<)*ZgF@e~&qLj#+YJICb^<)Fyu2$xFkR<{OFAkVdfb{Hnt^m*cd#{ zp5*N9ooidRrgx?0nv;KieLZxzoq<7bzI=e}LD5s;23vUA9`BFeKX1asg9|RtUGAG~ z{vuKFd|G*MXzSAlGiIDvqQY=s^Y{0aQ>WImvbdTcV(QNn=1>_<=pN8J_AYn)pm z=VoL@J-*=e>C>m2ZHwl_h>K43?w6DO^6gn@7?*j6ukYWWh#gti*8IG7?Z2DLow}bv zjc4uNJTXcC^TW;BokLk&-F=#?q0&@SSHYi7&bOSVY!|p#sBrzjgjFtL4wL+u7kuBT zqi`&Lr|sLGoZQ^MfBsCOsV-qBCuBvV+ zThd(hosA);?#~bJ#D&M#Mqg)UVqj4I^m@bG^@U%$>+61O*^y}OuiMfe~CyF1@5bP8@>6xxyC!Pgv;(A1D1S>q;&p6?X4tGbP>;Bp; zttzWlt=e7owrOF8lCtvNJ$sy-oWj<}<>uzzop+ewQN<>s{dKy}i#(IP_rAM(wx?%a zdpm#ZwjRgDQ>RR4X4s}5V{!hLm6qnq(2$TH_5UMh&fs}_`S_0T^%HK~c1T_Ud}wFLqlt;krM_YHBj)|BuI24h+qH_HUl3vxkFN}FU4ETYQqq*~i>3PV8HX1JuKfG;OWwP? zzi-ReyQr$(+_969;n<8BfASBvHQH96+8FWt-CbjIBclt~#O{fUb8s{%5-zcXm8{^oWU}qqDQHq~y-- z@_bKE&)vKXTi*M4x&{VDIyU+(=Hw2~Q7eAk#M)+C74o4%_36{BQ>W_l?!B=nU1c+S z@40*XChz%k`TKiDhPl?IMdhWYFYiYCdcHh#td~d1*Fm6}m&e8OU4XN*#KkK`4-V|S zcI|OHU+EmH(BRm=hmQ3wtNqQj-Q>bGyZwpHp6~Cyx39H&^0@fZwYA$neq7w!``@mj z;C2)1k~7P$F6Cxu2=tbExoVZx@g!bjlPT$&>t9`Z`t07`=x67bb9bsjR@bIJf9`*4 z!^7{dugkZ#*>UmOK6#wHt7hlE=EKJ}r%S((QRK;VxwRo#aYAUW;!me$ev4Dl91OGE z6qd*bMR$DhnB)@|7nhp))ZhN^l|ywGyuz404k=%ViZ@wgcVvaGNcb)evF@#jhuOHf zxy#GHU$}6gTU?)woqhMNU4C<|w$`#;m}jh-%gejpwz^{KR8yw*Zs(E z`Y}6dA0D!j<~KF@^ZS8gjDp6q0tu5@A1XYS&6u6Na`kJ@N)IQt7q8pncQ|Mn-1J%Y zc}pg@x?hi<-@Ene`9+tUuKO3+chu^|+uB#>WQ*Lbdeg40pMUzyOx}_{fwn7RX0vvC zn;ktQm9FbHb>)wvPD^xMN+eFC8Z6PBlCmy#_p{fpt6yK!eJkJFG>fY$)wDq+X3n)= zi)QmW3aF~8c6D_LP5QL%s^H_Kbrt$6cY;BCd;9L)znA;Z4_g-_DI&5Z>#Ej_3yXX+ zpR0RskKS(eT2sV}*;6g+!!z*SQ&%&yMeM1G#xoD|@Ve&kFf0gMd7*d1K7Vjq&z;NZ zSg$~(+3hnYJSy(Hnwf1r-rMcrqobp{G;n2`ZPlM83mtcfm|d`Jaw*vuVAkuA-O-v< zv){8NfKSyqvfH}lMa79t`HF3c>F4Kt{q}9zANHAN)22_EA|funeS^G({p3HNMY$&> z{Jj_zU$XhMKLkd)YiUz^QNY@_Nb{u zXKQ$5rMAI_?ygfa*ggk&Sm>}FUK79nWLlcAuW#wD(#ahL#z9{%J9o97=-A??;vTIb z7%m^j=NS>}rV!PnBRx?}Oe}71)z^oI+eNRw?3wLVSy}n}35&O{@6#es80x-Z-?vGt zo+qQP!Q;^HJzMv^5Sw)IPJ~E}UHqHKPf~7aTU%8QxAA`d`0?hBLgntGr%s)^wI%cN zsj1rbwZBx}6?zI3&bQzH^T)+ICp>~&+iU~t6a`cq9fENDT{d>dA~GA zZM}asUS0d4p{|~OZ%^gPlPA;9&zoygS>)Yz z@qoCmfJOv)V`FJq`RdBb)6?~*PoJKD@?xaUv|YQZ z!otETD=QbDR+VUDWn%-~%cG>kbZy1Tl~bor_x{wRc}Z;2j2Saxb{0LY|Nr~`$;s;8 zUS8kc-{;@U+s-Ge^=@J3qzNP%O~R-c+gdynV{_1=Bw zuBeq>R(?ni;6bw-1)OZPxFezQ;3X&RieA|J(QP|9^Z; zK3vJ_H^<`Q{`&tPA0IcbW?*2jnyc59aPs-*{Cj&UUtCb+YiDLi*nG2FT)(WW?A@K6 z#om4Q_&l1n1uQBkDv~lxYT3QJ+QH$#>FN6M`|IAmdbO+UZB${ucTSC_&Y8+R<>j!o z0Q#CQS1wh(|7EG03p;Zb_e8ec8sFPP#ZPi)`dCsjND^vRQfE3Zre0g=Rw6OKQ&t^XI}>6v-FPgYAy>#SVE z2l3dLn3`W-Uak&b4_c9_`}ND0y5DcN`^~k=%mRT)Ur(Jnb=Gc|!ONE~c{8~6+pUS;Uw2`F z<0H9Q5*Mytkx9$2NW5WCskw8hU@rTd1ci+o{7!6cOIqmjT$kmW^ z^5SCk_jkH+dv1!rYxWn2>qkBGQ&zyAN+88c?goy*H`V?!eIPrUxM z^Lw3^21VPgZQ(LpqPu3YTj-|bV?8rXGAHfdU+?5}XhY)R6Te=(cwwG@@61f&n$f}54JAP@Y_t~>&@7}#T-=^}@tE;O& zeE9J7^>zK&T_JZKXXRg+GNq>cectotXQHB^NjLmscWwDxcw0KNc#qJ@0pRtnOFy_t)1mGmRS?8*6`mySq8vzg)2B*|Fot zfB*V*Zl3M#{QLV-RND`8^78(DxqLp)YkxM^<(KEqne*)Y{P?J-sN2fg_V)HhMnVh; zDJe@9E)0Bpw>NRdoH;Sq*Tv2*S=QRxI@>(o&&Nk4``6dk>6w|9SyxsxGP7%4J>?u4 z7A7Vrc<^ZvFAvX_ty^DLmzvpxFf=qZIh~H*ts#`@z2NdoVKtu*-@n)Y{`U6H&fZ#QR1IMyS1h)Jmbc=e|zCl9ytXPV5~yZ7&li;EwY-#RdvDPQh~>+BgbeCAq} z9%x|v{QP`)NJ!4n%^G@oc12G**00z9`TOg~$H%$F^{%8_pL$rJ7rX1p)2F5tA08N| zpVQFL@R@7%_2Odp7`^hZudbdvdD45jUT#8*Mc?tGM@2>$<@xd1)2Baw{aRW^ z=Fax~`>ovKuU@~tE_b+B+FVFTC_?AjpP!$rw;y=vEmXs;YNqA5zwYmzJ$pR1KD@WL z`taeyckkTEs#g~BNJ@KlGJ5-$UFGq%+0(AHcJ2K3X6F0P&$oYjdpWP#ytGg$)Z1f0 zlunLGQDaUPcecdDQ@ipNZR$aD0s?mP|35uFebuT}D^_TTi;J&t^Y-_bH_wv^$zHN# ziHv=njg5^A!-93|_Emp>r|8@^VYhwFj}M=op4QURI+XPA{r>-TKR-QnY-X#huD&j| zy(KUpU`67GZ(mzpQBg59 zHPyXePSzm7;q5e0QBn6knVmI1jUo@HZN9!XdV9u2rNr*tZtY!(5w5PSvt~sZA9{6# zp|e$YN&WwST3T8G=NgwCx_I$ozr4MiY1Whthji7|(|>+?dhz1Lg3lixACKOaQ&?2A zC_sbPM$^p9?1}i=1q%v(eR+9{IU{6gXW|Xen1AeUHd(>tJ~JOZd**g8|Ki1q-D0|0 zIyy^&Rz7?7Y}vA9ZEbD8e*G#v)xzSqr{ZJM)m5P~eC>YA%irIdd;GDchDML9^|yn~ z?A71iaB^{3)&1GAd-v}iN#mH9m@IC7PfyR&)AjxR{Mu#*FY~#%Io*G{e5jIs+@6Sy zNvxpV!7XiV_x9D^zI{9TM$JSS<2zqIezdIpwWYV$_we`AZ;7`q%H@ z&dovA*4BmntK&GDc6@$y_1NZgLm6{pqetPPo^vBkq#1diI^AtKhfhz;>s6CSt%Guq zNaq$%5q8Td(m$Z_h!cyGgjn~|Wy_{bpT0f+z8znC@zYbHFPc*G^Z7y7NgsW3a}v-sx)>Om6pW0FLPA37{{2kfbokGoKMNKpfc6ul9zN(~$g{ifv0Frh zM5M5UL`G`r*C!{1t>*5l_?YDHf8MhA*_0_$)6(7etv#$Z*N!EqgJxJ9zA-LlasT+$D;U|kH5cvdV0Fm z+`11B9IdT)&zU20vHjHZ&!0bkmfrJcQPYt_w@f_!UfkQed*Q;wtSl{&QUjjPU%#$h zwQ81Kt(9ig)TvX~#_zYYvbuHg;>Z2}|NZ`czy9l&FL`%&vDRBlR|L8IT(t0MaqX0; zQ+Mv#m3M#N-~Ipp33IS`d3k+*f8W0BjfAGpuV25!#Kg3;we73EoVb3y{O+#O$?E>= zf~C(tEV#Blet*hIA-?v_85b9=Sh3=8JO5;^pZE4w|Nimg&+qs9L9^u?EZg(%%NeJg z=@Qj`u*q=AiWM1mca@$zdGge$Q?q8x`t<43zkmPA%E}@mBU5IdIC0|Y>TqTTy_g*n z=FEBX@ZrIS1@G?d)xMK(Z%<`wYwNXZ*XGTecY3=1@xni^uC6|N&Q$AId~T;#z(s?A z+#SWw{Zv(#&Y2@)Zod8OY;$oDk&fz^QU`$}YY#YVH%>Q@`JejbMe*zF+9pTU1rC4Q z^z5GZp(&p${)kCDU;gY#!OuT;UTy8?1FTk{QUR#_r;4B6%`e=!`68C``cH4)0wmO zkcafAPoI`}2^`)atnR0xuKxPft4Y_Yz6Qp{y?gt1Z{_E-p!pwOTvX<3zr5JJznx$H z-ObI%uU-50;o;%Hz`)DPe5=2_xcKq$an2?Uef{&NPCfcqvAgW;u9}}lbKKZjSk^>t zPP?@w^N{iNwuc24rN`QN`1t(1y@P{-c2#|S_3`oX+}zylIX8vm$$kNoZI=18oc@U_cx2khj@2}sVetzB|MvvN> znx^LF@HG(|OJ9eX%|4rAR46lj^5o|IQoJ^rx;7IxZ8B0=b^<_uRPotIFV>YFD_RHCNd3mLsomKk&-risDTHd^QW0-vG&c51cclYPNzP_&h z_GaZ}pOEP-h-;vE$oefedH)>JP6mdZ7Y7A=}}p8omy zdE2&apcRMnCiJemf1Ul3Sl78rOTGX8`J?7Hhl629-QQntZf)H@&5kX8n*owqo#?nyaC$4LX3++S=OOT>RRB z2aC>~J9kKJWmi|1b@{tDH#es{I5d3N^nHe5G7}TiqeqYK?W>g*6im#^n>S%X!Q*4S zVzGU%4A0E51U2HG^vpk-2HL9n<;BH?3l|2wy}V@MwmH){TBc5&8ooYmu2Jf#9!cX_ z>Gj$(O-vd3r%n}}>sSBpPi0xzw&~Nwr=Q-sd9!k|Y-&o1gXqVd#n0!=nR9Jzw0Ys9 zBVWFhu(PwT2wnHyGt=_>ySv}hU z84iD(F=dJn-}0MtPi&0HS}WoiQ8dvnSJ6mmE&s#$Y?0T+7ELhbaN%8$e(^}BaI5U| zq@*MvA)&DKalTgEu5NDk_EZWpOqe?L>$|(w0dpOr6$Bg>s%mRzAMcZ8YCLe`M#ZTq znu}y^9XPOH&z?CV-1}>OGBF6KPV1R7=ME?feSgP$KTk_btFNz*q2c@c``sB!!$Lz{ zmkD@yc}ZCorDSAiFfi~fySSud5&zcEjC=h+@^=fA%(`S|gEdHb3l7oyzO zPI3q?c9N2k;*&5?(ALhry)Cz2-u~LvtExvO7#wnP-ayU;F|Ggi2Xr*ux3{;sxVYN+ z<>wiv^W9u_PTkezRrj`_z`&0$E-J^y-YtE7?cn@$p2O$n+s8*maWPbUdcv7$eRrw% zbmR1MEiEk;R#wlRKHa%%*Q{ByCe3md>t4EQmDj?6lG0LPA)#qHk&|Z5)T~*sVZ(;i z;p>aCeCtC)LdxFWy1F&{`i~zK6&I#V5n*sBDl*b!Eq!;V^3eaA*6%kgTlc?z{%l7l zro?8QRE`cM=RmC`;M>OcQ1;@4>^xUdMFsI;}SGdEZNKCgP`<2~oodu6pnxK^)Ttt#YMQDM=1(4yc$ z!_uXx1qB5zE-jNMUq0F`zIN@}#OesAV>fR6@Lp;Z)u>;efjYE@^bS+Mo}6i%-q+X1*Xy=< z+qP!~7855<+`;RsA=1{~&di{rt6MnR(>Nj`;((#vpBZ!K#s&m1{C$&Xlj7yowdGa9 z#)#eJ@B6yCX4zC4$=pAEDKzq2Lr=|GmS5lR*Voq8s{7BYd3VS1#bWE~Z*OjI&*zu3 z*}+?_8o%$`oE58A`_D9DW%%&qq_9M&M(%~SYq7DhcjN1Rs;a9$|NHy9e*C^UrrFcJ z-2M1={ds*?iLj6mhok3`Hk#$!DERxUR9|18oxR;W|K0<(xQab)Zf>`?<$8O0efs{r zed8MYzh8uBpMCbRLR3^VL<>~e2&?&c`1#41=fz}XWW>hCrlzXSKKt)+zx}q{+h(=D zzD(DTKNOJ2z|epE^y$;LH>dj-78ZVgcQ?~SYWCT-U^#X^nH#&y_2*bMh8jNb|50NX z(%;wDcYS?){r`V|e}8}P?Ckve%gbi@zHQsgX8PAL8_3XN4=r;b?`@6(#M!7$L%bE40)g{#BO=URytyo)KSowQ{A zqAjbmj0`{CpE0AM__^6!tI{vWdb?&xMb@2o_OVM_+w?`rUA=tWH#rLGm!1U*M6M9H z_wz)j$i-BJi5~IRMtN;#&iK6Sul@aP>$YwCN?u-idwV-86XWc&Y7;$<_sf@;mDTGHrw<=) zc3XV(_;Gg^7mn6LoWg2<{(L@PkmWgToe&3$Q0JAJ)Df7!#RnP~tG~aydgV$;aB%eIw6l+ncAMwiDER#B z?7?RCei=(6W#wiEg^cWMdAph$Cr*G)W-`$3S+*>#py0!so15p`R)72U_O@DZa8z{k z|4*m&L$tQ8Tj!@D^zy}v8#iyxwX3z#&^U4TuC2NG_qVsVKYR9U+qP{YT&WipIQq}C z;pE}5DR|(}9J0b`p}=8->gwM!jnhG6JlEsv|9-t5|MlzFhl^SvmTFBy;5 zYrefbEkS~z;m*xN*{M@n=dLcxmR8bb4ZQ2b^x_PEISbSBwJ8fXZZu?Aa8SU}-97#J zxw&<9br)7%x^(H&hlhs^P89t8^;J83UCqZwt-ZaVVJ<#-I~f6igv`vH#m~>_MsNG_ z?(Xd)M_i7@W@l%=e)&>VOzhCdEnByKooSq2_Ws`AS65euua9G7;9xoQ;o;$h`r7|k z-d|pRer@#jKY#x&oqcwOVe;WN-l|PgMdeH~1ZMj1@qj=}_O|@{d0AOp{C}>j4BlP# zwyUe_*W-Ts2iyJp{OtezP>wsq$DrUHZfa^OD=T~W&>@0rn z7ZD-xPBJ??`}g1P_x)!YEj?@#Yds?@=FhKRzqW1L=IZMD?b|ore@bO#Wp#CRzrVfx z{NY2yz8cFp$_=YPoA+K_S=r7foAt?$ans+0h3=vgJ+3@3ee?9`)H!o%zP`G8=~56= z6n) zd}xIA>u1iG5wP;g+UV_la<)~Mm-)WGzrVfuZ?*qoi6@9(eY@2xPATI=2~ zw>ka%Jj3L+E$KR~PB*vb$9s63IM~dde}5k@!-p?l?rh7IUXyB7{_f6;7cVw!*zn}Z z6VPs+tgKHz|9trHq5Atf&elWw|NT0B_ipX=b+L=x`|axfR5&>~d3ti*(dl)YZItTu zn&)L#g6^9u%Iq(1wblRqDjw42=jSJTe{$2LNm^p3J;JA7yRsryMJ&F%f3 zZWXwgdO&&NzC!DH^X45pcFdr?%VgcUb^398R;*gJC}`y+GXZ{n{yXA)hc|dmRy)Wf zI&tE}&dyHj@^@428o4bE`ug?j=g*%lW%&L~-MqOtBV&aJXrfqY^2vCu>WsuhK`E)I z;NZhgi#S=DmMwc0ezxM~?c3*PnQCijaAfW5l{WW!{%iY&4Lb@RHn}J@<}IByYnJah z&22YAg*vwsKlhu_5cqAM>B;H;PO-AGf{qz1e(o0(B($&S#f61&U~qp&FzfT|;ByQN z8X~7=nP#t9yH;E;=0?JT=ENE6*RNl->eaoy)%~*8ZR^!1dg$rttqxnu6u&PxDx_;`Q5O{4GPi+nN`0s;aDo)(#!ntq(vwQHA^&jS{Q3l}ef21uVykAHV(Cv)kn zFVmi%o9n&&a&uFY!(SH5w#0So*7?t~sr>zI?XF$BX3mskIB?=b#?MbrPfyp+|LF4a z)vHx2SFVlQTlMnNQm2I*4$7`wx32Edk(*V{baQo|OlOJ_ zBZHWmoAaBuzoVmHXJ$oR^*7}!FX8F!+qQknlMfZ^FI}!x_lx=X_j~-Uz<0;{+ts!vu+&<5C|1X{Gf)@f^r(NJ_WpZ0~;CYNO@78Vh)v9V?RL7;oDjSUSweSO!iT6Jk^(WHrm`+3+Nd+*v< z>7jCIUF_~ZfB$Bh+?xK!$AD+Ds?(;Q>$0-5`{itRiQ48kJf5kOA-AXad0)w!YvuQA z%}ZZhd3dNF z7`CeB`?NYK2slJ?*fz{^%v{^5vF(HLhabC-9hP1A*UO4-+hpE^8ESq%{O_Q?nLpnI{jR=vX`&TZ@ISL_ma}myLaze zr^SN`TQ40@YvR$-v)eP5bC$eV8W|FJ@L5sjf74t1Tei+M%DHiNx&QY6U&H+pAGY1x ze0A2=VAGYlj11o&JqnYvy5bxC`&FOpg)3$DdU-bwHmf^3^RJJ)d*J!xqYp#OzcQ_Q zT=YG5*O7-Go9h1Eyn2q0!Qp^Gr^~0MdRuSoJGwY{X}~n!d2?pWof{fppsbMae^YAn z?=KVc|J?qclDND`sdd%_A2rbJu0N-FW$WhL4#`^U)|jGLq~uz{xL|95qtE^F_xIMW zTshMq(djcEr_w|XEv;1x79@Oob92MesZ&L#pKhILCJ@im*I8UtbgV~GIalt@&CTi} zExKV>G8#No9u@7(F`Ioht@u@={Ku~`df_TUoGeT)|F`~UjqiMItMOc6?T61ftOpMs z+*|cEYF9}n`16SBU ziw4-k*S$$jEBi3N{@?q5f4%$VbY~ePKAoZ&ANpqBo@LK2TwR^DYL)umzYEo-c&Hd^ zYBDtZ=;_(?Dob*0LI2~&$-%*l46FNOBp<$rh>Vt=IAOurgc|b{kJqv-9m&l)qnpHtTX&iJN+m2KU*sXQNB{L!u%zY_BhPvq|8p zu1ksJgR^%GmES+p)Yj(a<~BAnld~vD_^i8l@nUhkm=g&GY`jt_$;r*@k9&(NNd?5k z^{G!eesuG!S+llo-8ysT%$++e+YkSIy?*}^-vZv8Lhzm)hly$e91px$4UM+*DNW?q zTb~%nE-XBG-K;QoJqanPY15|F{rch=9(R&|wv*tR)>CTSw_j=|u3CS7!Da8&tAvb< zcbo4j_H}h0KlvPd{ImvCUew ze*e|u>ZI1MW}Cb3>hb4pDJcaZA`Aykp33T9zrHkhU8hi|%d~HsWahR`ym;2O zdJ{Ld|D8ROpT4|YGuK+Tx3^qB^3tNG0ZJ1?Hb%HGEiAk?C+xE1T#rN{i}dv8V$o%u z?hKI*8(Zf)mU0WmvK`$hW>^2uMqT~*@#D*nUfelx;>2y+%3NJrr%%8B?(Xipckezt z64#qkx@d|(diwKY=e5PUr%nI?h3R^=)+$1rZI>=)Y+?I0_Z{jK-L==AowTUh;*7A}5yaq-2!zgPdi z{x@1hOxU2uZK4`aA0Hl`I$y3ev>xVcDt)j^f)5KIniN*k)h?Ds;|#>7XRN- z`1sM`_RVGQzRb3({d#3(uXp5wBTJW_y|uHrsr&Rr)9hQPc5!@WUb$_naAr|ak%xyz zRMf2b^W$%aU0%C#<<9c=atsIF?tir3bILXc)-K&YJe$OncJUN!Sj2J7DMEEUXu8@i zW$oIv@9*uE77+OG;2^WTy?srs|IgGY=Pf6f=FEq!1z<9o`A1dp`jR=5;}%?gsTI`u zJE~VH=4b4rxV=@SudnHETRYM*D@$#UohBeUE0_w}YqNvB_4X4i}7`|^45gp&*m zOpOhR5oL*qskyqH9yTRkvjSaPcW*XsORUw^UF++sZ(CLJ`)6^Z$B}16o-U6*Zq1%s z{k7`HtI(9BB)`OkzO`2*r}%f?ys7;A>*>Eg3jZ(lzPn}X?-{d$PdstlTWz0c#O&AMWj~))6~>=+L3VhZi3`b|Q*nf{u%3xiwUcPDbX3&;*Sy^z0VM=O}zon;##|)Fqpt`!c**U!~N-I}^z@-cmsb06G zLDD)N_1*f9|38jDdGRgF#d|Ym%*eU3W8%q_RV!DX+^s)<;^RGbo5Z$RmOXpst>(wW z!)N#7y#3!bYuRtzI`q|rsnJPozrNP2J(Zii=i5DNYb&U&acgr?`WX^;?&;@+Iwr5L ztquSF?#vpUwq|NO z-(C;jqp1%U+}nFnB`BewAfdn@W^?U9rr_FIh68stCNG{m`EXLwq~fA!_21_%n+bfhtJ~+Iq&U$-gsb)NLl1ucuV26V`ughX=u~}q zadF9$$zkQWNz0r|f+BRn0s|*5U7Gq?i2JYs>zZoK z`le>~R;7up6EEJ9G+q+x9Q*c7+@GK6qTQ~cfeXDQ-`TM7p4wd%x=a1rv}v2&`uVCp zG&m^mlo#BvNcUS`b##T_;<+Eb-!J?2X1}4~Z2!53>K8dK_m?(2boE@Hi^{v0EiZmA zbgrH~^CYO{@j5-X_Wkzz*XCOsJaejvvCHBaoqI5 z*aK81$trMf-n8l8kH`GQ#h*>HuW77ld~s>1_y0ei&-Y53N9hDz@jG(tn461>PRx!A z8|uDo&$X=h@Zjs~>xMki1_=kQt`4`a`5_=E`0>xr&!9p0u&`;MHAGX696vsL_H2Gx ztC9x?8rQ8`XB0NuMMG3jQ1IWsf7jQ=zP`SG{+{2Tt}0|F9>2V9orV?9v3%{YGq*RZ zzrU=0pPBvN&5h3O2N@UwLPMtc&f9b2vhnxF$KM=sVrVdHPE5{PwQ||D2T}#)Wq$h# z994u&XC}6KD7-ikaCMrC%DbpNH|n4HNKNH>JK<8ss+Fw2ezNMs%$PCts5sZvkkH>z z5gzJ%SJMoZERE`Zn`&vLEj;Wbw#Y4Dp!`6>v6goK>ft-RI|)Os;1%E%8FX`g3%y|VhfMZKM|{rs6TU+PV@n&hGq zHG7uX%+JA6RnbeF4jQca$t1B$O6r}_lC4wr?$vQ$zI2`4nS+ks=7wFlHZ3glWoDMt zwwH4r{9n@=Upy~qq@k?(XjX{{H3V<=oueW^baIudR#aW_WRN@$qkOZ>Rq0 zycET~G(F63+w zUtwY4ZZ*F(t5-MM&FS6$>C>lOrLV){;_e;mmG+-&bv5yMmyeqBTXu$u@9*wjUJklu zMM_roY|_SecXzWp>$F{291y80Sfr@0&kvffx|(SE;NJvOdwMaQJA^CXU+L)b2R#v}0 zK0ZEK-9K;tyc$kpFp!s@|L9SY1|Mj&Ixg-XXcG1OJVu6?m>3&d5SUoM^3o;7a({d`QhRA z8&!UDEEL!8QxpM>_g9uLG(B z6LDjo1x6(=F67?crnwK)rTHQN zNl7&|e>wz}t#Yo%JNf#aJ$#s1EoFE8|9^+u`J0=YH*ebX;QngY!XcsA#>pZIoc(}xca5BtxvIeF?-(&n25yc0dPY~0v*a|0`rV@Ae{ zt=ZREm>B1ufBvzez_F}*&Zn=7BG+J3H4pJUUvsE;}h{Q_9In zZ*On6ulrN+T4h9+^-a~C<@rdnW`$Irz~~hMwG|mi#Kx2_Qh zyUWANYieS0|HXXnC&3vJ}i-@AA3^l5LastJC}4;M;L^pG*ly0Rj0vDBYA zr=QwYe0cEf?QIvO!ec#>huiu8Us}9i*)lg3q0FqTB#E%Vz>S%g)iRHtzPGm;RQcZ6 zkofJ}w;ngwjU^=|I=Z@#A3eHs`EomtGNl=U%3(z9{&9Kb9Yx)-RNySpj+ZVM@j?%Um0|{KIX${_|`;e)(ck^(DiY z@14^^0|f=pAyXe7Pt^{Wv#BU}bHlK-v~*#>iL}k7B_*?TWEp>cd3pKLQt!!=CqF!1 z{r#OMyS$sb`~Um@|K7j1xBBasFHKENWp8dUGFa69`ZB{XS%T+ShoG{TmzP_Q#K%WR zyKmjP#mvrU68y5!Z~6CESGCQ|%nS_$Pdd4D-vM0=)yh3<)~rjLK5V~Vx4YmW=$!Za z_5XR7%XxWw_siKzNlI#paIFk^_3P_vb)Oj*p0F)Fy&@zu)VA_d%8w5ZFPxXWr)eU! z_G1a#BG&~vA=@T4Z#tQ9MEvLbsR_x+n=>z~`TL*000x^jZTbKL1cc$?E=R&YlHjkH|<)=_SjT+gE+ju(Y(av8g%UC!4e}LPt#A zzHZO9ZPQ$oa_{akwY2>C;^JcGc0SOdOBXI={9`%Pk$b-19Q;J0tz%HQ9s{q%(MPi|~%Y+~ZVExzm5 zt$Q~;zHa5pm4$_clGbH8#<5)7+`EgOcJ18x^Kd(VZEY=R#bxa7vYWSVNl8eU>L<~rs-|{sj^*Y}n?Aj{xfyic=Ep}z-`(B)`qisGIoq!DJ5|)woiFIz4v5o>9l8sT?#>Si1tXWh2{oSSbi5@Bv1s1ixO5EMuYyNhN>#vL3 z`|8=VvJVd&Gwy5X>(_sN#>?>F<>lp*)qD>btl6<+$EHm|Rn6x4_j>yJ)C4$Ihpo+0 zd#c%ge0S+DlRQCG%(2MzoDzK|8~u04$GkM z@b3=~v%fS2?YF4;X~b~g`t|E4PH=qWsXm#3ztJLT=8BqVE3m;KlgH|gQK=AY{(PoA8AcNeRzHzUK+rArTj!KziOn%Viw zNGcnlM z*vwjQuPwr5DZ@9_E7wq+x5aO{w6yg8+TYtYZ3>Exwl+20x?;tP8h4ooxT$<9d@aN}P^{j$Wr-xXX;ewxgmn>nqC*IoHdV5>0aQ9I@KE8SL<}ozv z*|X=yjfnjG{EA2>1}-kHbMtJu86Ff^Jbd_YhfYs#Z*O<^=FOWQf2^1}bLMjY`DW(k z`wJdAm6d&4?A{-tGi?svM%{_)w+i&i2zYVG*S|brqnMNr0{DA@_@+!(^F+)w(fz!AvBEW{R*T;S=lB~POgfh?Rn<9K_wthJ z9dBNyShv}KL}#CE`zpPl_VIy6=ELp$^0rkafq{aPPqKW~w0ivbv9glVrAwE>*2l#L z2RA3)04==a_gNmG@$1(w8NTPw=hyprdrwY{a8nWD=Hhy=dc~eSG7Jk=ul{{nfB%yw zPxjRR|M&Ft^v!8!Cw)_By*E!!Pw(@yv&}`@qqpZtNlEc)?T?O#;AnNa(`jODEuEH| zy8*NfNN@V-1cO^<5B3GOyjR}G#nzmdoE#hyA|fOd6cO>_`E&MkrDc9|S1n!IYJU3s z`S$4#JqK%bCv2 z^*(p<=Plh2QPBlX&d!GqA5Q-M?(X^d_WSGp{^Ga)qY(Q^%hVLK&4ycCPsS+a#KXhw z(c8Pt95b_pA1HJEJSL`fWCPpTpNax!9h4?c(~JG};^N}z`tfnQN;v(mcPdZxxVOJv zf39WPr6r!%*T=_i$(T5AUR-76&Ip}t+qT`?o*y3;7Utq|q*vPfSf8x-^2>Yo?oCck z4qqF^%1}^P`11Dl{Hm&5bLYl(|NZ#&Ywfo;kwHP1etdkKX) zd}f(+x+vBD_^@!@x<0?<&e@4UK|+6a?%lh0)v8zX>;G9+d`QU7&VH%e?V{A_a_ISI zOBueIKL1`WpI;Ck=InepZg17nrAtG@!yoTk_Tlfu88a-3o}9RI=g!~X-^*WLTf5QN zr>e?|f#KNw?TTL3D{rz{ z#xs4|f`y{D=gl=v_X`TTba}b|t}^p{xGo}8pw{rz3)#)us{GU!W+`20Eerk}3;`Dy8@ zRjc;y{d;}hw`Y#m4yC==qn>`Cfw7%mo{xiL!~b{0RMrW;-L{T=V*n@kKVLS9YN zjppL!ei+l-*LN;||6jALD;hk9|NQ+s)92aYc7FFBiNvg|tjtWy^m8)c&g!v~lhq%a zCT!WdHT~S2%8!p+&1SD%z1lkAK*Nj~GoCzolGe3GLq|h+FFQgA)=z?>FMFt*5TIH!L_wP%Gv&rrKL$>VhjuVckbLbP3&X#vA;r{Du&k9 zCRVf51#Uz~Rq;x@g$8C`*tq@uJ<%!Cq+Y&=m^tgxY}p`g*F{=l-gDi4Ps++&=IELF z)V^Q-_@2t-+l$-J?7cWTM zT|a;I+_~QK?c%Go6q?L>V>WEtHf`RVvuDrh>gi2emwe}=LS)C9))x=^9JVmDC31`F zfo>2=OMBMFE3Fr^BOxt~ZGY6pq@xoQouy`+@KCw*>gww0da+T_(W_Uk?6lAGP`UIE zw94~-fBl!QAi&D-=l=h{@r8vSk8}#B%Dj5{`gQ(Y(2-(med~4?KR@?=|NnjEwQ^0C z#m_1#D-XW0kZAk)>({DPtKQw+{r$$qg@ujFyq%q$Uj{gzm|>XwK}BAmy83tI z<}_ATRzZ%Ihlktq@9fxExbSJSfH=5}*v18SuO?B7Sa0V&f3_o<3KW1l9*4K!@`=G&`Yxw+qtjw-6y6aMn$K4JBB zKmPp|H#Hca@YGnLy*n>+B-%=0Dp7+IJuCOYgsvZwO%r%#`1YHR=h zd@i4HZE?^_W;ULTPwFCEuE#Q$-@0{cwt0S3M8uXCj#|pMZrxh!-ha$KNrKH(vBg8> z+$klrA;K5g~Uhx`9FfthMOy)EFBbH%uYenGVTPeY^{Ynws z6LoS}^Y8DC{_}dh{OMB}A*+_nnl)?Ht81InS88c3{`Hla;R8z(yMBDwl`B)cj%I#l zWttdq=Hcg7rNDgS%`=4685lHo?)0BLiD&bRSj}>?}Us_clNI^PjV` zU;EGd^W(Vu_s5U;)f4*C9|}xEL-)VD{ME#?zqQ51Xy!*rN#!|n zvTnXPyWGG1=n)2ns@XGjKYV!b@n^k#O~v!0-5o70!vFq#h_JA^*L~&6_oGMO{r$b( zzL4qICs%cK`@a79Z=R`nGc!0Og4(dsia;`AO%tB~PBr*;#A-;$U+kA0O&6o-g`ywQoKw|9Xno%ldxT@<_*Pht4x| z%-A2dZ?2<)h!{J2d+FXPkbHfg$betfz0^{_VBS>bbOUZ^}aM6Nl!&*5;gE&+{)( z?#b_cVS#~+C(gfk-6-XzATsanj*ENi>v;}O+O#>Hg+*p>VY0*exK}1p)6eU#UwQHJ zl{eA(7LzVtxnW#yj*j(^vz z{T{nV;@_{YD&gTghof==jz4|3yQK5*W7fZaHm{y5%h1q}DAeh))-E+9>=+r3^gom&3)1(W*ocXxN2nwko$ z`Mh8{e#uY6sgX;Cdq0=n{CX3M8Al(^`S4x-@f)6SwY2o&pMSpa%ij|f{knC-gO5K` zuU?&Y?3mTpz&nwwtP9W2V`Rt($$EU@pnhUr1_wjLzq{oD!cJF0LbvNjd{EQZx3T-j zuD{@dRu}_=d>81B0R6lFudO=zkdtNmJln$tIhW^H9{W0Z>bkPB@BDHtr>%I}+Gfp~ zxb7VjKflc_fx{af_VvBqvlp}^DkN!9Y3-b!tG|4io|&0f`&%w%=OlH3n3^9Oc5Iq- z_VL5KMQ2SaWaQ;%&*EZ;D1CB*!~OWFTemt|SaR7O@BSr|zp}FOq@|@^?XNSd^@UwS z11&dIZS6^anf>z9HK(PlOXvLL$+L95r@-OzIYGj#@X?XL#cmg8mG1wkwICF9Xnh`~9E2z53QgIx9}@1|3Jswzg=ySeJ{8-TYOHlGZH0p3*F3Hv8<;GIN6k zf6SSEnY*;LwX?HTg*taGe3-8tzUp#@7E|NWiGk;i_shuj`z7G`I9F-a<;b)9B{Z-8yNE>Tern;Nyd!}T%WwXZrI2%D7^h!J>RyP%geid zj#;mdlB=rPwM{o)nao-`G4R&!?<*@Sl{WrZZ(Xh=Bs9%$R!VA8P(W0a`qcZb;m*#@ ze(O^ER-U?XC8LN@ zWPI@C!;k;gJoCBr`+M*IQzAw)S1p?K`{Uvgh9w?4`@X)pxvyLNEHAG*j~oxfgHJ!1 z76#mU<)Jvay5x0OTr6+>jT+{C0TIFB>G` zi4>ui*EXlC=iN&w-WhMDWb5m@s*CHLOH^d^>={!-gCcMK;+FV%tyFiy)(^%j=GZbR z>bE#0y1B72ENGuHpWjIglB{%E%w4~~yBiqeY^>pQN-{GXDrMx~zj^Jg zCbatXD+Y$>+L~uQl9Q**{cEDhsa7)6vZ^E`=+c_G)(_V|>yzD`_Up@;KYvafIkNrz zJ>Hf2-<(whI9Qmjq^ZcxEiQkz$IJiwY&+RoXKmQ|`KmrB7+6@CnO?nnbLQEn`+U5+ zy}gCIkM5hSrkiQ@+O*T-%UqL}kzQUwW#4|3zD~%JVPNoASg~RyXT*kt#`fbZ;?s-E z-&f_`Ft~a4?6osGs_N=mh8uTmy5tf5y=A}sm5NfCz5ta^GB!8%-b)r^FgSAW-=V$L zlJa&pzP-4Z)Qr5M#_#w0idYSyM2WQgOv~acE4umR|5g3`w0^q&>+DQN``eRFd2P_eYA(3z({3>ju{yR$Zb6PpN2Q5w zy;7lpffuLRcv!QkaL-=2`LvU{`S#=e^873L<(*7Al|)uJZ2GWz!KxKGGtJj;*kbbc zFgrs-UmIvfzvtC;{=)|UC5>l9M)Jq+m3sKZr0&6i#rFSpe1GJ+s_T@$aeCE`9Ri#! zs>14S>gvm%JbSb$VD`a+Z5w}@tEE-cYRt{;m%I1X@YS_t%hIo{zb`BDMa%YYXq6QM zWKl(gaqX_;^Ye61DOP)*W@qQ0$H&dx*`>99h_*Jv&2~`_U)TCON%Gdr<}Ny@b1ROiziPe8XBIo zv^>54w_mVtuf9Ob@#FWqdqulk>T~bBc=rZ$MCQf6zvcH=8Y>!aUb0Cjuf=VtpQIVj z_KsPludl7HE>8=a_IQzN!C4OVR!4z)qnS;oBn;PSHJ%I`}@Y-T(ip-v=&U;+u7DZ&mBN zyV^qCt0Q!bCZ71=wZ5)ackNm`yZEksivw5Aes<=jNxGlx^wSSNnq0jiwfyiwgPc$G z{Y(rF%Y*)}TfgIW5_754+m`+Rw{1@U-`jb!=9S1jw`RZjW=Sk(e7#fz+`hhM_fD$8 zvOY#_$Bq;8_AlqOdYHg`k>`htu~tB1&^d)&d^L9Q_hKDN1WrWV;F%jMrj)`M$^De$ z%%@Ac?k7nsySmD1UGL^qH@>|P{QZ1>j$U}M?_2N7EG|q7XBr&JZ}(ezYSO!P1$D$8q^>ecPt2Khe1*d!a%pcc5Aj%OYJt4i<*gz#u+QF*89Y zgiT~&-oi_K(h1xATRtsXQ}$_HSJM$Ot!X+g3Rcz^qE=kWQB`%h&S1M^7UNu*REK>V zTfN@7XbEsVz02AX^K#KOr=`CCdR~duvCRwvuSI)meY5$6#Dq_oY+pjc*#dGF$~azg zN?qQ4>*UmLcN^EV&Uaj_?J}J)l6xxqmj_vFB}G#hT~D~I+Tyo_NrhW5R&Z(WIq=%f z)c9sKD=#R#<1jIe9nuN>v%Mr z)Pj~yZS~5~eZt8i?KiLH!>3g3I_-%bkBp7(iHdH`o$j@IRT(?$(Y>|W?CH<+UfHeP zV!ZZ5)DOuT9^L!pJWLUkD6^oLdE_C*;`@40~vSqxxr%ah!tEO|N`uVxvYlT*K9Mf2<Q9-TUM?Y+|EIcwJbo-w=o=p$Zr|7FjfZhbf{nk|xhx7ZI6 z<02(i(3$zV3DWA(acwR?5`fS30j74p1&DpQv2@tHQ>XKih!rKR=v zc3oWzcrYu~yPLE;1h7AGtWE~Z5F_@Z~R+ckfe0N~`^h^5= z&tkEhsAR>XH=TQmzt|&wnZV~ft3(?)ahJJbbQ@fQpT%;WqUwm?x)nzv` zZ_)`JHC{dLi#_l_vJCe0e5tafU8$har#hRxjfXJMS#& z;$mQ!>9T0sVNMmn&V9n_b`?L3o}HcjX_izhZ?D;XSK*St$Q4`O^Hn@3I-k5gVv|55 zw_t4ZxEx^6}wwlZauCib;&iE zd*arBeRl(NKm$r0>khuTx%ugi@=h0}wfCd;HnctPby5&8F*P+cG1;Eq}wn z@Mm+f`{ZSN&iwhKyWF4KRcY$4n;Y-+%k#(X7Mnkjk>SGC3pX|vB-TqyGc+_#o3?sx zY>iu6_Wga|A3xf1_HJ}yEdwdsqh!niLbF`iR;c@MU5BcNv**tr`{N5D)z5ta~ zNgJ>?X+TMq2h@}4Ei1lA8p1|Db(4cR^uR^hN&ZA5We#_s9{KfcU#!l`lLZBaT14w&7;M!jIdX zCcb#{_O__0>FVWvrJFV}F}SFxtg$KSIQ%&C!poA-P@cnon3(<7Mqj^u#%HdZ_0P-x z1;O)AoxT~Pw^WmP0iJQ63oPlWNloT@8oIF`f4@IzY5A$|+V6Qk-|yeAuf@br!7FXHDSrQzJ$t(O<=_4N z{r>j(`SauU-wSYO-}WaYZr_*f_dkd%uD`qUaaQ{D?=LTJo-&0kDb(G$*=^yI=~J#; zxvCg>Bg=qiwq5P7-R19Dm>B2CwD>1jgmX+^dfWNGTBC!)dcN&aFDBe)2wypqd*=d; zO&3m`^78jzzh%pppP!#6AM2S|UM|oPcIjDi5{uJGMWId?rHz|2L|u*t1O_s$1syu{ z>794`rHPvY{DXs|WA`q)SN!AP>FKZi=2*OZZ-3;T9K#1C)2~%2OSZ0jd5BZkfM>V3 z`1Vg17Utc$6<+?P;Kfa0h62>BP?n$Tx3Wv$WH^+Rw63My`?O81?%G&!J&BBL^Ng8u zCL7nkzt=TOioxODa{v06{rk#a1_@2nnCQV5yI1OD$~QA(>kUb*jnl*!8fH%2`sn-p ztC?G*%%o>&BnJiE6Bjjn@g=#g?y!uE0nh9=&$g8v>k&53|5y0<*!lExb3S}uzG}sl zO*idj?dQ#zy!dQ_Ozh^gg86o?qM}L@U-V0xZ{p$t4Wdk~y8GHdLMkh3>ej8PSFi3^ zmE*o^g|H^Wg9itj&zd(x34YgTj1uf}dpXm3V}#Dz3MF0rpFt+~_|E7sZ04Ep`r!11 z=e0~c4sBuG;rq!u`@t1y@KwjFR;?c+oAcKS9R@d^hAr zRUsjv2TfC_POYiFbg(2vEr_i*EiHp1HBonVt&z!=W$WJ67Jd|9(8|uP-(AGYu%Y%K zlPud}JUgde<@J`tCRZ>WT$_I0fakVJI$z$e;;ox^|Bs)jAkum7$+LMJylay-ZaX{w z{e{iX_uSpRSV-7@>h#&KUO%5crNy=$G_qFq*lma4LXVQyH;pGo#2ii9d-Ckt%p4vD z19KA=aO%BtQSiP|sB7xeDVm)(Z*E+z8cWv}RXSbZo4DaPY3=lF+N~*x07}9=)%0OY8+pV+{9gX4Xlo^78T+8pQSEPV83Z zU;*u@5)psl$Tj7B)QyRj>oP*L^qwaL1qp55%*fCmxA)PfpVJQ?p1f(Zc#pjOlKglE z2QNRrw7P$@GB3-#4y{Tm`}XyO2ZKXm}uDwEnpL z`^3fBb&bD2e$=8Qn8U`95x0h`MM+2&R_qa9x$?JuY|!$H zJ*B0Ojg8Ca*w=rUU$4{KJKb-d&RiCw?~mOlM(9lRSTuRE_}lyIy%w(d@voZUz~b=r zEnhj4rKd0PKXXoR-HH{dn>AB5?OUeSe*E{(Gc&h*e?R-oCC1l29x9V=zI&HEXRc!{ zS3sxBq)D^686JH6xq(;3PC5DT$;k{2@zeFUZ{N~W`B`oG1_1_!WTwQcE_)2S58sp( z5t%x}qVj}*wy>~K=at)Mco;rhpKIO!__4Q^HuvdM|8^7});9=Hn&=~Ew4^aTN47mU zB5ax0!YvUE8*^Xm@>{}TxyZq9NmW(VvuDpLD=P~NL953mmzRUaI~pA{lzh~L864Cm z`}+Dmy*r(6X>;nr7bHSyi-N#vOBWY%nlV671YJSG#*i`JOo?)Ol2plaqxh@%W+E%kDiII=Z$+ ze}CP*5i$GS-MhcOZ8bC9`}vV;m}BGd1&$_KrgqB8N{WseLcRrsKP&Iq?D(iEtiI37 zzu(g{a!dXGFPRrFU)hpb@~~D*fBlM;l6RK-PxcJHEq8R?sfHIzG$w2nxbl1Ay?wRO zpcXnP>=t&oxVUVH&7Yn5ns8oB6&ve6@7yX zNzcyAv@YAQ^G;}7T!7cj=qNjjiVNkJRAkwj&pxfw(f%C~78Vzqo1bZUDdyYf^vh@8%+VH}ta-St^XMb@_3`d1Dl1m5ns;}@!Sp*8 zJcswV_ir;XtD8FS+_R!miJsus*Lq8ePU-3Vd3>z5R$Sk%=Ig5Utw(>Jo!!4Sw)?eJ zsnpvyZM@QH=gVH-zU~oT{-%L(^PRh%E{o>wtbD9v(RuS`j9q=e`+HORJh@d;MO+nb zarrFN3UhaJJ2&56p5eonmzU*it4{2;hL$hgo|Dy7)zz69uC0lD{QkZE`*$T8;p<{< zZpjp8crahqn(h0`%Z$}eKK<0(xwH1nnW!(lEt#<^w6xAnPhau4C|u4;g${4*tofux6?zU^JxD4y{GjxH<}n38JkQI%XFI9 zp)cSe-{GRPGHD|N!>p{VO(CxJ)8^Qee}7kc@80S?dzM_E9v-fLW4r#_zrVAs%fDs& zAAcska^-(*wV%J=H!t_i-Y=^-MVJ-TTimg8=gSu_rc9qc->Nk0{k^@P-tmD7PXP|r zM=vig4-gjOV3C)XuPk4__+^565CcP~YpTQgxJ_GBggC5t+`7-c2zj;BclN4C9k#jm z6m0yb#hR?N+$za4{odY>X0^IHzsy24yIfSvZYZ}UUTQlo_VCkBq0S@c?!;83zB}G; zFJt%T+uhw4|NcH)aO7L_t%u?aPAda+)Psy>?nyZ*#BgA1_VrVzPd~jY3og+GxQ;Az zZhvrShGBBa>&?De{IdGox0#h1L0fv)s)ew8va4Mbyz-LV5~mo95U$DNT&2{*-d@-{1MCPCq<816NGEJ}k^1?9Xguu^l?s_E! zrzBOE?^&j1VDY1{=FhDI2eiaiZ&=eab82dG)~bce+P2>`tFOEC^Rwda^2e2tw?%iY zdi8(WgslP>`84L}urTPv?U~`CboJ`hzkfcT|MZR<5_3NvKYu=b@?_@e&(F?Q_imc7 z&i#6U(p#Mu1`@@`dKek@c=*2EpK|iU)z$VQ;?=zJdUKDpxu~3)v2K~~f`yfgF4c=V zHf@OBo);Gv$Ivj_JU?z{k?Xy?j^M_IqW`%$mc{@7)$YhiOIy}*GELz`)QyP=65Ial ze*f?8_Vw=D^PDDnXbACc-7bH3|9+X>OS_QAHRO#LPmxgyGpa8 zqGs*c-RuA|*2&Ib=9${x-wvj7wHAGQ6S*|h{YRsDJPXssHj6KBD!(6Se0smu{Lsml z+ox(jt$P+4P@}o%Za_ffiuLRHH}`V39y;1Be*Mao6T96ZInxSsMCQ?M@opETABES~ z#fF82eR?}dX5qE$Nn)!xlx_)_hF(2e{QTejX}Yq)4mWI8otP+bF;8QK4hzGM-Mi1v zGUa9nh=}-cnBU%G!cG}*b(^YWYx{Ti`+bMG!+K?{uid^Ky)Sl>{*J8^+Lp^XYDlg+ zm8Iog!q63ZZXY8fBjd)68!Ia-{pZ`o?ksvbQQ3XUwR{$^F-sp^TIyZ#Dv-h9`@6gS zGL}lm|7kGo*gE0f%9MoxDncBsObY`Pgg&w|?Ksn(-Qu(_;Ksqfib5P4w(R|4uhi(E zHc@Jgih#(unosu5hDOsAZZWn_YhAT!)$xA$`BtS`?T2?(f6q%yeE9S8^HPxmP2iiA zSr*NGP_#38d*0rK`- z%zwV!^K)}wUt2p{rh2KugKLMB!DoH{&wqc^&r{~U z4{tfka>)K}T~67ybzfffXY#NF%Ak z2N!&@&HvE5^~a-mdz}^K{LPlvG??TwebD z=g*(Hg?V{-xw&hn-WC5ZB_|hV%XH=Ry7kAu>^m>^zuA7%1%sZ~uV1&ewoaZb{PWtj zw^y%TefjdG+MnCz3=9?aibt+pyY}y2-P@$-=xDXco@$di?f#xl*2)dH(N22iyB83lk-#dZkuZS0^Vw z{`W6#T~A-%vdb?kY}S<-Ffe>j+`fIgQ%7?%vtrBZU0dZ@nHp!#ocZ+W(|!BwLLW0W zC>gpHvjY5Ss5DQZES6KuB$FBeYz`eWymL+^Pl(ZTHUmD*REY+;^Nb%O}lmb z_WmM2hK48Cb*7vD9j^)*9J2qN zJ7iv7UjDPjZu)6auGZPJXBX{^+1)S6zz}d~+glG6p-vYoE2}5j>FLjJ+_-V%h>O2} z|Ere_4D0uKikVf_)J&=U@!>;)L|f*pyLaz8J3Fs>&cwjL(D3Y2?q&f71_p)?ayDQ1 zL5wYKVqlqTC!9ds3sR9BAUaB!n~{Nm!67?E5yZ{t)nNhAYbUmXs0F5*z^0I>`7h&< Wt09b;OilVALp)vmT-G@yGywnx-WCo3 literal 0 HcmV?d00001 diff --git a/specs/mvp/mvp_roadmap_v2.md b/specs/mvp/mvp_roadmap_v2.md index 36bde09..d6704d4 100644 --- a/specs/mvp/mvp_roadmap_v2.md +++ b/specs/mvp/mvp_roadmap_v2.md @@ -1,96 +1,49 @@ -渐进的 AI Infrastructure 演进路线图。从最初的单机脚本执行,到最终的智能化运维平台 - -对应架构演进图,设计**基于 Native Ray Cluster 与 Verl 框架的 AI Infra Roadmap 设计文档**。 +这一版的设计采用了 **Overlay 架构 + GPFS 核心存储 + 无状态(Stateless)节点池** 的模式,逻辑非常自洽且具备极高的云原生弹性。 --- -### **项目代号:AI Infra Roadmap (Native Ray + Verl)** +### **项目代号:AI Infra Overlay Platform (Stateless Ray + GPFS)** -#### **阶段一:核心内核构建 (Foundation & Core Execution)** +#### **阶段一:内核构建与验证 (Kernel & Verification)** -这一阶段主要解决“能不能跑”的问题,聚焦于核心计算引擎的对接和基础任务调度。 +*目标:验证核心计算逻辑,跑通“提交-执行”的最小闭环。* * **v1.1: 原型验证 (Verl Task Spec & Ray Job)** - * **核心功能**:实现了最基础的任务提交链路。 - * **组件**: - * **Ray Job Tool (Ray Client)**:作为客户端工具。 - * **VerlTaskSpec YAML**:定义任务的标准配置文件。 - * **Multi-Verl Code Path**:支持多代码路径。 +* **核心功能**:实现基础的任务定义与提交。 +* **组件**: +* `Ray Job Tool (Ray Client)`:客户端工具。 +* `VerlTaskSpec YAML`:定义多代码路径 (Multi-Verl Code Path) 和任务参数。 - * **基础设施**:Handmade Ray Cluster(手工搭建的 Ray 集群)。 - * **目标**:验证 Verl 框架与 Ray 的基本交互。 + +* **基础设施**:Handmade Ray Cluster(手工搭建的集群),用于验证核心代码。 * **v2.0: 任务管理层 (Task Management)** - * **核心功能**:引入了服务化管理,不再单纯依赖命令行工具。 - * **新增组件**: - * **API Server**:提供统一的接口层。 - * **Task Management**:实现了任务队列 (Queue)、映射 (Map) 和重试/重新提交 (Resubmit) 机制。 +* **核心功能**:引入服务端,管理任务生命周期。 +* **新增组件**: +* `API Server`:统一接口层。 +* `Task Management`:实现任务的队列 (Queue)、映射 (Map) 和重试 (Resubmit) 机制。 - * **基础设施**:仍运行在 Handmade Ray Cluster 上。 - - -* **v2.5: 资源与用户管理 (User & Node Management)** - * **核心功能**:从“手工集群”迈向“自动化集群”,并增加了多租户基础。 - * **新增组件**: - * **User Management**:用户权限与身份管理。 - * **Node Management**:核心升级点。支持通过 SSH 管理节点池,实现 Auto-managed Ray Cluster(自动管理的 Ray 集群),不再手动维护。 - - - * **演进**:基础设施层由 Handmade 变为 SSH Node (Auto Managed)。 +* **基础设施**:仍运行在手工集群上,但控制面开始服务化。 --- -### **阶段二:产品化与服务化 (Productization & Serving)** +### **阶段二:架构质变 - 无状态节点池 (The Stateless Shift)** -这一阶段主要解决“好不好用”的问题,发布了第一个正式版本,并扩展了业务场景。 +*目标:通过 GPFS 实现控制反转 (IoC),彻底解耦平台层与计算节点层。这是本架构最关键的转折点。* -* **v3.0: 正式发布版 (Frontend & Data Management)** * **里程碑**:**1st Version to Release!!** (首个对外发布版本) - * **核心功能**:完整的前后端分离,闭环了用户的数据流。 - * **新增组件**: - * **WebUI**:提供可视化的用户界面。 - * **Data Management (SFTPGo)**:集成了 SFTPGo,解决用户训练数据、代码的上传与下载问题。 - - - * **价值**:用户可以通过 Web 界面完成从数据上传到任务提交的全流程。 - - -* **v3.5: 定制化与推理服务 (Customized Task & Serving)** - * **核心功能**:支持更复杂的训练需求和模型推理。 - * **新增组件**: - * **Model Serving**:不仅能训练,还能部署模型服务。 - * **Customized VerlTaskSpec YAML**:支持自定义参数 (Param)、奖励函数 (Reward)、Verl 代码等。 - - - * **价值**:从单一的训练平台扩展为“训练+推理”的一体化平台,且支持算法工程师深度定制实验参数。 - - - ---- - -### **阶段三:可观测性体系 (Observability)** - -这一阶段主要解决“看得清”的问题,确保系统的稳定性和模型训练的可追踪性。 - -* **v4.0: 系统级可观测性 (System Observability)** - * **核心功能**:建立完整的基础设施监控。 - * **新增组件**: - * **Prometheus**:指标采集。 - * **Grafana**:监控大盘展示。 - * **Alert**:告警系统。 - * **ELK**:日志收集与分析 (Elasticsearch, Logstash, Kibana)。 - - - * **基础设施升级**:在 SSH Node 上部署了 **Exporter**,用于采集节点层面的 metrics。 - - -* **v4.5: 实验级可观测性 (ML Observability)** - * **核心功能**:专注于模型训练过程的指标追踪。 - * **新增组件**: - * **Weight & Bias (WanB)**:集成专业的 ML 实验追踪工具,用于记录 Loss、Accuracy 等训练指标。 +* **v2.5: 用户管理 & 无状态 Ray 节点池 (User Mgmt & Stateless Ray Node Pool)** * **核心机制:基于 GPFS 的服务发现 (Service Discovery)** +* **Ray Head (有状态)**:由 `Node Management` 启动(通常通过 SSH 或 K8s StatefulSet)。启动后,将自身的 IP 地址写入 GPFS 中的 `Head IP File`。 +* **Ray Worker (无状态)**: +* **Stateless**:Worker 容器启动时不依赖平台指令。 +* **Auto Connect**:启动脚本读取 GPFS 中的 `Head IP File`,获得 Head 地址并自动加入集群。 +* **Watchdog**:Worker 内部运行看门狗进程,监控 Head IP 变化。如果 Head 变动,Worker 自动重启或重连,实现自愈。 +* **新增组件**: +* `User Management`:多用户隔离。 +* `GPFS`:取代了之前的 JuiceFS,作为唯一的共享存储和元数据交换媒介。 @@ -98,33 +51,83 @@ --- -### **阶段四:智能化运维 (Operability & Intelligence)** +### **阶段三:产品化与高级能力 (Productization & Advanced Features)** -这一阶段主要解决“自动化”的问题,引入 AI 来管理 AI 平台。 +*目标:发布首个正式版本,并支持大模型训练所需的复杂网络与推理能力。* -* **v5.0: 智能运维闭环 (Statistics, SOP, Agent)** - * **核心功能**:通过数据统计和 Agent 实现平台的自动化治理。 - * **新增组件**: - * **Statistics**:平台维度的统计分析(资源利用率、任务成功率等)。 - * **SOP Tools**:标准作业程序工具化(自动化运维脚本)。 - * **Agent**:智能体。可能用于自动故障诊断、资源自动调度优化或交互式助手。 +* **v3.0: 正式发布版 (Release v1.0)** * **里程碑**:**1st Version to Release!!** +* **核心功能**:闭环用户数据流。 +* **新增组件**: +* `WebUI`:可视化操作界面。 +* `Data Management (SFTPGo)`:用户上传数据/代码 -> SFTPGo -> 写入 GPFS -> Ray Worker 可见。 + + +* **基础设施**:全量切换到 `Ray Worker Node` (Stateless) + `GPFS` 的架构。 + + +* **v3.5: 高级定制与训推一体 (Advanced Task & Serving)** * **核心功能**:支持复杂的科研需求。 +* **新增组件**: +* `Model Serving`:支持模型推理服务。 +* `Advanced VerlTaskSpec`:支持自定义 Reward Function、自定义代码、Checkpoint 断点续训 (Resubmit from last checkpoint)。 + + +* **网络增强**: +* **IB Network Supporting**:支持 InfiniBand 网络,确保多机训练的高性能互联。 - * **愿景**:打造一个具备自我管理、自我修复能力的 AI 基础设施平台。 --- -### **架构层级总结** +### **阶段四:全链路可观测性 (Full-Stack Observability)** + +*目标:打开黑盒,监控基础设施与业务指标。* + +* **v4.0: 系统级可观测性 (System Observability)** * **核心功能**:监控集群“活着”且“健康”。 +* **新增组件**: +* `Prometheus` + `Grafana` + `ELK`:指标与日志平台。 +* `Exporter`:部署在 Ray Worker Node 中的监控探针(采集 GPU/CPU/GPFS IO 指标)。 + + + + +* **v4.5: 算法级可观测性 (ML Observability)** * **核心功能**:监控模型“练得好不好”。 +* **新增组件**: +* `Weights & Bias (WanB)`:集成实验追踪工具,记录 Loss 曲线和训练参数。 + + + -| 层级 | 关键组件/技术 | -| --- | --- | -| **接入层 (Frontend/API)** | WebUI, API Server, User Management | -| **调度与编排 (Orchestration)** | Task Management, Ray Job Tool (Client), Node Management | -| **计算引擎 (Compute)** | Native Ray Cluster, Verl Framework (TaskSpec YAML) | -| **数据与存储 (Data)** | SFTPGo (Data Management), Model Serving | -| **可观测性 (Observability)** | Prometheus, Grafana, ELK, Weights & Bias | -| **运维与智能 (Ops)** | Exporters, Statistics, SOP Tools, Agent | --- + +### **阶段五:智能化运维 (AIOps)** + +*目标:迈向自动化与自治。* + +* **v5.0: 智能运维闭环 (Operability)** * **核心功能**:降低运维成本,提升稳定性。 +* **新增组件**: +* `Statistics`:集群资源利用率统计报表。 +* `SOP Tools`:标准运维工具(如自动清理 GPFS 垃圾文件、僵尸节点检测)。 +* `Agent`:智能运维助手(基于 LLM 的日志分析与故障诊断)。 + + + + + +--- + +### **新架构核心亮点总结** + +1. **极简的节点管理**: +* 利用 v2.5 的 **Head IP File + Watchdog** 机制,平台层不再需要维护复杂的 Worker IP 列表和 SSH 连接池。 +* **扩缩容极其简单**:只需在底层(K8s/Docker)增加 Worker 副本数,它们就会自动通过 GPFS 找到 Head 并加入战斗。 + + +2. **统一的数据平面 (GPFS)**: +* 从 v2.5 开始,GPFS 承担了 **数据存储** (Code/Data)、**状态同步** (Head IP) 和 **检查点存储** (Checkpoints) 三大职责,架构非常收敛。 + + +3. **高弹性 (Resilience)**: +* Worker 的 **Watchdog** 机制确保了当 Head 重启或网络抖动时,集群具备自我修复能力,无需人工干预。 \ No newline at end of file diff --git a/specs/mvp/sw_arch.excalidraw b/specs/mvp/sw_arch.excalidraw index 6846cc9..3504791 100644 --- a/specs/mvp/sw_arch.excalidraw +++ b/specs/mvp/sw_arch.excalidraw @@ -28,7 +28,7 @@ "version": 137, "versionNonce": 1825387201, "isDeleted": false, - "boundElements": null, + "boundElements": [], "updated": 1766542010726, "link": null, "locked": false @@ -93,7 +93,7 @@ "version": 95, "versionNonce": 1906899791, "isDeleted": false, - "boundElements": null, + "boundElements": [], "updated": 1766542828321, "link": null, "locked": false, @@ -206,7 +206,7 @@ "version": 47, "versionNonce": 735402959, "isDeleted": false, - "boundElements": null, + "boundElements": [], "updated": 1766542828324, "link": null, "locked": false, @@ -258,7 +258,7 @@ "version": 144, "versionNonce": 669214625, "isDeleted": false, - "boundElements": null, + "boundElements": [], "updated": 1766542607009, "link": null, "locked": false, @@ -916,9 +916,9 @@ { "id": "byUzC11GrMczZ4zqYYsOv", "type": "text", - "x": 336.5608702481786, - "y": 1135.0000356038408, - "width": 665.4595336914062, + "x": 151.32274594572323, + "y": 1123.5714641752693, + "width": 474.09967041015625, "height": 25, "angle": 0, "strokeColor": "#1e1e1e", @@ -933,20 +933,20 @@ "index": "aO", "roundness": null, "seed": 744930127, - "version": 208, - "versionNonce": 853256975, + "version": 269, + "versionNonce": 1824553827, "isDeleted": false, "boundElements": [], - "updated": 1766549560779, + "updated": 1766652340448, "link": null, "locked": false, - "text": "v2.5 user management & ssh node pool management & JuiceFS cache", + "text": "v2.5 user management & stateless ray node pool", "fontSize": 20, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "v2.5 user management & ssh node pool management & JuiceFS cache", + "originalText": "v2.5 user management & stateless ray node pool", "autoResize": true, "lineHeight": 1.25 }, @@ -1146,7 +1146,7 @@ "version": 51, "versionNonce": 1728634671, "isDeleted": false, - "boundElements": null, + "boundElements": [], "updated": 1766542302682, "link": null, "locked": false, @@ -1250,26 +1250,38 @@ "index": "aX", "roundness": null, "seed": 547560097, - "version": 870, - "versionNonce": 1722832943, + "version": 873, + "versionNonce": 619225773, "isDeleted": false, "boundElements": [ { "type": "text", "id": "EdzWwm2MF4NMEwDTCHfs7" + }, + { + "id": "E54HMdTWr17WNGZr4bKzy", + "type": "arrow" + }, + { + "id": "g8yoH0XNAZPrHQnD1gT12", + "type": "arrow" + }, + { + "id": "1OTBUOX8TnoNfvXNOzosg", + "type": "arrow" } ], - "updated": 1766542814263, + "updated": 1766652192424, "link": null, "locked": false }, { "id": "EdzWwm2MF4NMEwDTCHfs7", "type": "text", - "x": 563.5970272134504, - "y": 1267.07425236382, - "width": 175.0598602294922, - "height": 50, + "x": 609.4969905923566, + "y": 1279.57425236382, + "width": 83.25993347167969, + "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -1283,20 +1295,20 @@ "index": "aY", "roundness": null, "seed": 1770593921, - "version": 959, - "versionNonce": 2044944527, + "version": 968, + "versionNonce": 621658093, "isDeleted": false, "boundElements": [], - "updated": 1766542375469, + "updated": 1766652077205, "link": null, "locked": false, - "text": "node management\n(ssh, ray cluster) ", + "text": "ray head", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "Ht1PA24t0iCjp9fLeiRlr", - "originalText": "node management\n(ssh, ray cluster) ", + "originalText": "ray head", "autoResize": true, "lineHeight": 1.25 }, @@ -1320,25 +1332,33 @@ "index": "aZ", "roundness": null, "seed": 1857196929, - "version": 168, - "versionNonce": 1534425423, + "version": 170, + "versionNonce": 838236653, "isDeleted": false, "boundElements": [ { "type": "text", "id": "x_QrM2XN9-uWyiDwYKJeK" + }, + { + "id": "qb22Fcjvx-7GTklXswBZF", + "type": "arrow" + }, + { + "id": "1OTBUOX8TnoNfvXNOzosg", + "type": "arrow" } ], - "updated": 1766549985174, + "updated": 1766652192424, "link": null, "locked": false }, { "id": "x_QrM2XN9-uWyiDwYKJeK", "type": "text", - "x": 893.5364475030988, + "x": 900.8264407892316, "y": 1348.4479764568691, - "width": 143.11990356445312, + "width": 128.5399169921875, "height": 100, "angle": 0, "strokeColor": "#1e1e1e", @@ -1353,27 +1373,27 @@ "index": "aa", "roundness": null, "seed": 1022786401, - "version": 236, - "versionNonce": 1685248463, + "version": 241, + "versionNonce": 1814771501, "isDeleted": false, "boundElements": [], - "updated": 1766549985508, + "updated": 1766653805070, "link": null, "locked": false, - "text": "ssh node\ncontainer\n(auto managed\nray cluster)", + "text": "ray worker\n(stateless ,\nauto connect\nwatchdog)", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "w0BT8pr6dKvh_W3QmJnsG", - "originalText": "ssh node container\n(auto managed ray cluster)", + "originalText": "ray worker\n(stateless , auto connect watchdog)", "autoResize": true, "lineHeight": 1.25 }, { "id": "wi4Jwct5iVv9xd0Gcj90L", "type": "rectangle", - "x": 885.4776376186145, + "x": 883.0967433663824, "y": 1207.0102147192865, "width": 163.1357446724801, "height": 110, @@ -1390,25 +1410,33 @@ "index": "ab", "roundness": null, "seed": 1095690945, - "version": 217, - "versionNonce": 1367848303, + "version": 222, + "versionNonce": 1016867629, "isDeleted": false, "boundElements": [ { "type": "text", "id": "hWpMW-1Y8-8WY-suBBG1X" + }, + { + "id": "ve8kWxmBWCQyXVX3YwJnj", + "type": "arrow" + }, + { + "id": "g8yoH0XNAZPrHQnD1gT12", + "type": "arrow" } ], - "updated": 1766549979775, + "updated": 1766653783354, "link": null, "locked": false }, { "id": "hWpMW-1Y8-8WY-suBBG1X", "type": "text", - "x": 895.485558172628, + "x": 900.3946572065287, "y": 1212.0102147192865, - "width": 143.11990356445312, + "width": 128.5399169921875, "height": 100, "angle": 0, "strokeColor": "#1e1e1e", @@ -1423,20 +1451,20 @@ "index": "ac", "roundness": null, "seed": 8776353, - "version": 291, - "versionNonce": 977104911, + "version": 375, + "versionNonce": 695625549, "isDeleted": false, "boundElements": [], - "updated": 1766549980394, + "updated": 1766653801280, "link": null, "locked": false, - "text": "ssh node in\ncontainer\n(auto managed\nray cluster )", + "text": "ray worker\n(stateless ,\nauto connect\nwatchdog)", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "wi4Jwct5iVv9xd0Gcj90L", - "originalText": "ssh node in container\n(auto managed ray cluster )", + "originalText": "ray worker\n(stateless , auto connect watchdog)", "autoResize": true, "lineHeight": 1.25 }, @@ -1885,10 +1913,10 @@ { "id": "PXtF1zNsnvNxKU_H4jeH9", "type": "text", - "x": 559.5796160014972, - "y": 1682.5067098403679, - "width": 175.0598602294922, - "height": 50, + "x": 605.4795793804035, + "y": 1695.0067098403679, + "width": 83.25993347167969, + "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -1902,20 +1930,20 @@ "index": "ap", "roundness": null, "seed": 2100021601, - "version": 1076, - "versionNonce": 1197540111, + "version": 1091, + "versionNonce": 850785091, "isDeleted": false, "boundElements": [], - "updated": 1766542690628, + "updated": 1766652371189, "link": null, "locked": false, - "text": "node management\n(ssh, ray cluster) ", + "text": "ray head", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "eebtjBKkYJE3VF_lVR2oE", - "originalText": "node management\n(ssh, ray cluster) ", + "originalText": "ray head", "autoResize": true, "lineHeight": 1.25 }, @@ -1955,9 +1983,9 @@ { "id": "yBtyY9co8Ab9jgSrqqGY_", "type": "text", - "x": 916.299027441048, + "x": 910.4290323238605, "y": 1763.880433933417, - "width": 89.55992126464844, + "width": 101.29991149902344, "height": 50, "angle": 0, "strokeColor": "#1e1e1e", @@ -1972,20 +2000,20 @@ "index": "ar", "roundness": null, "seed": 2122294561, - "version": 364, - "versionNonce": 96320993, + "version": 365, + "versionNonce": 822564781, "isDeleted": false, "boundElements": [], - "updated": 1766550017013, + "updated": 1766652363864, "link": null, "locked": false, - "text": "ssh node\ncontainer", + "text": "ray worker\nnode", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "c435CnHbTETzqC7gKyhIm", - "originalText": "ssh node container", + "originalText": "ray worker node", "autoResize": true, "lineHeight": 1.25 }, @@ -2025,9 +2053,9 @@ { "id": "WAFYosAqYimAcOXDP213P", "type": "text", - "x": 917.7427640782187, + "x": 911.8727689610312, "y": 1627.4426721958343, - "width": 89.55992126464844, + "width": 101.29991149902344, "height": 50, "angle": 0, "strokeColor": "#1e1e1e", @@ -2042,20 +2070,20 @@ "index": "at", "roundness": null, "seed": 1306497249, - "version": 413, - "versionNonce": 346419521, + "version": 430, + "versionNonce": 715164643, "isDeleted": false, "boundElements": [], - "updated": 1766550021028, + "updated": 1766652358183, "link": null, "locked": false, - "text": "ssh node\ncontainer", + "text": "ray worker\nnode", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "mfKA7of0hnZM0OQJfcYru", - "originalText": "ssh node container", + "originalText": "ray worker node", "autoResize": true, "lineHeight": 1.25 }, @@ -2648,9 +2676,9 @@ { "id": "PLea_3C6ZwhUvAxzMYPUt", "type": "text", - "x": 911.5528910409748, + "x": 905.6828959237873, "y": 2210.6702489851323, - "width": 89.55992126464844, + "width": 101.29991149902344, "height": 50, "angle": 0, "strokeColor": "#1e1e1e", @@ -2665,20 +2693,20 @@ "index": "b0A", "roundness": null, "seed": 1554579809, - "version": 455, - "versionNonce": 1729568367, + "version": 456, + "versionNonce": 774445421, "isDeleted": false, "boundElements": [], - "updated": 1766550026291, + "updated": 1766652379177, "link": null, "locked": false, - "text": "ssh node\ncontainer", + "text": "ray worker\nnode", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "UCNeCZDb_1FxxqXxFbnKE", - "originalText": "ssh node container", + "originalText": "ray worker node", "autoResize": true, "lineHeight": 1.25 }, @@ -2718,9 +2746,9 @@ { "id": "5YoG8i-Ye5ly6-_S-cpdQ", "type": "text", - "x": 913.5020017105039, + "x": 907.6320065933164, "y": 2063.5039436993734, - "width": 89.55992126464844, + "width": 101.29991149902344, "height": 50, "angle": 0, "strokeColor": "#1e1e1e", @@ -2735,20 +2763,20 @@ "index": "b0C", "roundness": null, "seed": 437181729, - "version": 508, - "versionNonce": 1515785711, + "version": 509, + "versionNonce": 1147892301, "isDeleted": false, "boundElements": [], - "updated": 1766550024498, + "updated": 1766652377361, "link": null, "locked": false, - "text": "ssh node\ncontainer", + "text": "ray worker\nnode", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "j0ctOKOc-HSrAaeO20QQv", - "originalText": "ssh node container", + "originalText": "ray worker node", "autoResize": true, "lineHeight": 1.25 }, @@ -2921,7 +2949,7 @@ "version": 29, "versionNonce": 1699453391, "isDeleted": false, - "boundElements": null, + "boundElements": [], "updated": 1766543076329, "link": null, "locked": false, @@ -3819,9 +3847,9 @@ { "id": "ilnIrrhwIwdl5yMP0J_-o", "type": "text", - "x": 909.4364935406329, + "x": 903.5664984234454, "y": 2879.93006993645, - "width": 89.55992126464844, + "width": 101.29991149902344, "height": 50, "angle": 0, "strokeColor": "#1e1e1e", @@ -3836,20 +3864,20 @@ "index": "b0g", "roundness": null, "seed": 1027284705, - "version": 578, - "versionNonce": 40809217, + "version": 579, + "versionNonce": 291944867, "isDeleted": false, "boundElements": [], - "updated": 1766550032641, + "updated": 1766652384276, "link": null, "locked": false, - "text": "ssh node\ncontainer", + "text": "ray worker\nnode", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "XS4hxHD7hkjqqdIPoHNyM", - "originalText": "ssh node container", + "originalText": "ray worker node", "autoResize": true, "lineHeight": 1.25 }, @@ -3889,9 +3917,9 @@ { "id": "7cAjOCsL26YdZ9huXHSvG", "type": "text", - "x": 910.2382968258621, + "x": 904.3683017086746, "y": 2676.163290367263, - "width": 89.55992126464844, + "width": 101.29991149902344, "height": 50, "angle": 0, "strokeColor": "#1e1e1e", @@ -3906,20 +3934,20 @@ "index": "b0i", "roundness": null, "seed": 1071094433, - "version": 602, - "versionNonce": 242547585, + "version": 603, + "versionNonce": 1021609667, "isDeleted": false, "boundElements": [], - "updated": 1766550030479, + "updated": 1766652382577, "link": null, "locked": false, - "text": "ssh node\ncontainer", + "text": "ray worker\nnode", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "qYCyZgffVShRdxYkZZEQC", - "originalText": "ssh node container", + "originalText": "ray worker node", "autoResize": true, "lineHeight": 1.25 }, @@ -4920,9 +4948,9 @@ { "id": "MlWwMns8GvsbUSPuAkpxn", "type": "text", - "x": 909.269020275922, + "x": 903.3990251587345, "y": 3575.4886425780674, - "width": 89.55992126464844, + "width": 101.29991149902344, "height": 50, "angle": 0, "strokeColor": "#1e1e1e", @@ -4937,20 +4965,20 @@ "index": "b1C", "roundness": null, "seed": 199467407, - "version": 664, - "versionNonce": 245158063, + "version": 665, + "versionNonce": 461995779, "isDeleted": false, "boundElements": [], - "updated": 1766550037488, + "updated": 1766652390408, "link": null, "locked": false, - "text": "ssh node\ncontainer", + "text": "ray worker\nnode", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "_jFvOMU6QE09vt670MiYY", - "originalText": "ssh node container", + "originalText": "ray worker node", "autoResize": true, "lineHeight": 1.25 }, @@ -4990,9 +5018,9 @@ { "id": "UI2QBOWsvPD_E4TkC2r6A", "type": "text", - "x": 909.3059286295896, + "x": 903.4359335124021, "y": 3375.546197614518, - "width": 89.55992126464844, + "width": 101.29991149902344, "height": 50, "angle": 0, "strokeColor": "#1e1e1e", @@ -5007,20 +5035,20 @@ "index": "b1E", "roundness": null, "seed": 1627769295, - "version": 683, - "versionNonce": 2016853569, + "version": 684, + "versionNonce": 379757869, "isDeleted": false, "boundElements": [], - "updated": 1766550036548, + "updated": 1766652387677, "link": null, "locked": false, - "text": "ssh node\ncontainer", + "text": "ray worker\nnode", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "tzm932P2Nq-2wiRjYiVPx", - "originalText": "ssh node container", + "originalText": "ray worker node", "autoResize": true, "lineHeight": 1.25 }, @@ -6721,9 +6749,9 @@ { "id": "yqwCJHk3GkcnyEzFHSU2z", "type": "text", - "x": 921.7836483625952, + "x": 915.9136532454077, "y": 4341.633440344149, - "width": 89.55992126464844, + "width": 101.29991149902344, "height": 50, "angle": 0, "strokeColor": "#1e1e1e", @@ -6738,20 +6766,20 @@ "index": "b20", "roundness": null, "seed": 202300239, - "version": 732, - "versionNonce": 1344398287, + "version": 733, + "versionNonce": 786567267, "isDeleted": false, "boundElements": [], - "updated": 1766550043686, + "updated": 1766652405206, "link": null, "locked": false, - "text": "ssh node\ncontainer", + "text": "ray worker\nnode", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "lQT5-sydwGN_vbadu61qE", - "originalText": "ssh node container", + "originalText": "ray worker node", "autoResize": true, "lineHeight": 1.25 }, @@ -6791,9 +6819,9 @@ { "id": "BTIKScHoPW_m2eS-Ro8GX", "type": "text", - "x": 921.8205567162629, + "x": 915.9505615990754, "y": 4141.6909953806, - "width": 89.55992126464844, + "width": 101.29991149902344, "height": 50, "angle": 0, "strokeColor": "#1e1e1e", @@ -6808,20 +6836,20 @@ "index": "b22", "roundness": null, "seed": 1266520975, - "version": 749, - "versionNonce": 1943252289, + "version": 750, + "versionNonce": 1196796365, "isDeleted": false, "boundElements": [], - "updated": 1766550040560, + "updated": 1766652402009, "link": null, "locked": false, - "text": "ssh node\ncontainer", + "text": "ray worker\nnode", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "n74P3TAm78jOVPnYqFccD", - "originalText": "ssh node container", + "originalText": "ray worker node", "autoResize": true, "lineHeight": 1.25 }, @@ -7866,13 +7894,13 @@ { "id": "_Zgu4gR2fktoVHKaUQb1c", "type": "rectangle", - "x": 1060.450408091689, - "y": 1228.3990155355616, + "x": 1117.1171910157962, + "y": 1245.5418726784187, "width": 78.84661364258261, "height": 188.34936741723558, "angle": 0, "strokeColor": "#1e1e1e", - "backgroundColor": "#ffc9c9", + "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", @@ -7883,26 +7911,26 @@ "index": "b2f", "roundness": null, "seed": 158833537, - "version": 1080, - "versionNonce": 307738095, + "version": 1159, + "versionNonce": 1195930381, "isDeleted": false, "boundElements": [ { - "type": "text", - "id": "natjxKxjBD22rIy9kQo5F" + "id": "natjxKxjBD22rIy9kQo5F", + "type": "text" } ], - "updated": 1766549478990, + "updated": 1766652171061, "link": null, "locked": false }, { "id": "natjxKxjBD22rIy9kQo5F", "type": "text", - "x": 1069.2437481771406, - "y": 1285.0736992441794, - "width": 61.25993347167969, - "height": 75, + "x": 1128.9305201149195, + "y": 1327.2165563870365, + "width": 55.21995544433594, + "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -7916,28 +7944,28 @@ "index": "b2g", "roundness": null, "seed": 1239057249, - "version": 1093, - "versionNonce": 1288478913, + "version": 1176, + "versionNonce": 1497401709, "isDeleted": false, "boundElements": [], - "updated": 1766549476497, + "updated": 1766652171061, "link": null, "locked": false, - "text": "JuiceF\nS\ncache", + "text": "GPFS", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "_Zgu4gR2fktoVHKaUQb1c", - "originalText": "JuiceFS\ncache", + "originalText": "GPFS", "autoResize": true, "lineHeight": 1.25 }, { "id": "CG6yONl8gUpLktvzyo8Sr", "type": "rectangle", - "x": 1054.891663837564, - "y": 1645.2816569685795, + "x": 1059.1773781232782, + "y": 1639.5673712542937, "width": 78.84661364258261, "height": 188.34936741723558, "angle": 0, @@ -7953,26 +7981,26 @@ "index": "b2h", "roundness": null, "seed": 1242056239, - "version": 1124, - "versionNonce": 1742603681, + "version": 1134, + "versionNonce": 1925466509, "isDeleted": false, "boundElements": [ { - "type": "text", - "id": "Sl08WIR6gCRTqD4BpOt6g" + "id": "Sl08WIR6gCRTqD4BpOt6g", + "type": "text" } ], - "updated": 1766549488214, + "updated": 1766632669322, "link": null, "locked": false }, { "id": "Sl08WIR6gCRTqD4BpOt6g", "type": "text", - "x": 1063.6850039230155, - "y": 1701.9563406771972, - "width": 61.25993347167969, - "height": 75, + "x": 1070.9907072224016, + "y": 1721.2420549629114, + "width": 55.21995544433594, + "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -7986,20 +8014,20 @@ "index": "b2i", "roundness": null, "seed": 748529743, - "version": 1137, - "versionNonce": 890929327, + "version": 1149, + "versionNonce": 625193325, "isDeleted": false, "boundElements": [], - "updated": 1766549485662, + "updated": 1766632670642, "link": null, "locked": false, - "text": "JuiceF\nS\ncache", + "text": "GPFS", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "CG6yONl8gUpLktvzyo8Sr", - "originalText": "JuiceFS\ncache", + "originalText": "GPFS", "autoResize": true, "lineHeight": 1.25 }, @@ -8039,10 +8067,10 @@ { "id": "3Q0gbzE9c5TcDs5CvDwr4", "type": "text", - "x": 1058.6319112776123, - "y": 2125.407965539058, - "width": 61.25993347167969, - "height": 75, + "x": 1061.6519002912842, + "y": 2150.407965539058, + "width": 55.21995544433594, + "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -8056,20 +8084,20 @@ "index": "b2k", "roundness": null, "seed": 1948319041, - "version": 1185, - "versionNonce": 587857377, + "version": 1187, + "versionNonce": 1804836451, "isDeleted": false, "boundElements": [], - "updated": 1766549492711, + "updated": 1766632673274, "link": null, "locked": false, - "text": "JuiceF\nS\ncache", + "text": "GPFS", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "rCphrx7mF_YEPITUD0218", - "originalText": "JuiceFS\ncache", + "originalText": "GPFS", "autoResize": true, "lineHeight": 1.25 }, @@ -8109,10 +8137,10 @@ { "id": "JUS2JvDrkx5GXQjxkI78l", "type": "text", - "x": 1055.0945706274674, - "y": 2772.7131767775427, - "width": 61.25993347167969, - "height": 75, + "x": 1058.1145596411393, + "y": 2797.7131767775427, + "width": 55.21995544433594, + "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -8126,20 +8154,20 @@ "index": "b2m", "roundness": null, "seed": 314541409, - "version": 1209, - "versionNonce": 353658817, + "version": 1211, + "versionNonce": 883072717, "isDeleted": false, "boundElements": [], - "updated": 1766549504559, + "updated": 1766632675825, "link": null, "locked": false, - "text": "JuiceF\nS\ncache", + "text": "GPFS", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "Z88ttfOpQUtGXPUZOTNLh", - "originalText": "JuiceFS\ncache", + "originalText": "GPFS", "autoResize": true, "lineHeight": 1.25 }, @@ -8179,10 +8207,10 @@ { "id": "OlaiVvlhdGvnYHx9Km9Qv", "type": "text", - "x": 1054.5892891205633, - "y": 3473.5812810878424, - "width": 61.25993347167969, - "height": 75, + "x": 1057.6092781342352, + "y": 3498.5812810878424, + "width": 55.21995544433594, + "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -8196,20 +8224,20 @@ "index": "b2o", "roundness": null, "seed": 2094399297, - "version": 1272, - "versionNonce": 1971998753, + "version": 1274, + "versionNonce": 1657497347, "isDeleted": false, "boundElements": [], - "updated": 1766549511124, + "updated": 1766632678507, "link": null, "locked": false, - "text": "JuiceF\nS\ncache", + "text": "GPFS", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "YMHES-uetzrMEDwFkCHdH", - "originalText": "JuiceFS\ncache", + "originalText": "GPFS", "autoResize": true, "lineHeight": 1.25 }, @@ -8249,10 +8277,10 @@ { "id": "5tfhe4hwG92sJ3lHY77pG", "type": "text", - "x": 1067.7273485037017, - "y": 4232.560459710278, - "width": 61.25993347167969, - "height": 75, + "x": 1070.7473375173736, + "y": 4257.560459710278, + "width": 55.21995544433594, + "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -8266,22 +8294,437 @@ "index": "b2q", "roundness": null, "seed": 1717876641, - "version": 1268, - "versionNonce": 26062209, + "version": 1270, + "versionNonce": 1662877741, "isDeleted": false, "boundElements": [], - "updated": 1766549517790, + "updated": 1766632682521, "link": null, "locked": false, - "text": "JuiceF\nS\ncache", + "text": "GPFS", "fontSize": 20, "fontFamily": 5, "textAlign": "center", "verticalAlign": "middle", "containerId": "py2gWjP2fsxBZN-9ttfBh", - "originalText": "JuiceFS\ncache", + "originalText": "GPFS", "autoResize": true, "lineHeight": 1.25 + }, + { + "id": "kABR_mMoETidxEIaDhQ4Z", + "type": "rectangle", + "x": 1068.4072277735147, + "y": 1152.1940900233978, + "width": 72.3809814453127, + "height": 85, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b2r", + "roundness": null, + "seed": 1528005795, + "version": 152, + "versionNonce": 1931450765, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "C3GvN3smKvMNJQtSMOFZ5" + }, + { + "id": "E54HMdTWr17WNGZr4bKzy", + "type": "arrow" + }, + { + "id": "ve8kWxmBWCQyXVX3YwJnj", + "type": "arrow" + }, + { + "id": "qb22Fcjvx-7GTklXswBZF", + "type": "arrow" + } + ], + "updated": 1766652208471, + "link": null, + "locked": false + }, + { + "id": "C3GvN3smKvMNJQtSMOFZ5", + "type": "text", + "x": 1081.747735280839, + "y": 1157.1940900233978, + "width": 45.69996643066406, + "height": 75, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b2s", + "roundness": null, + "seed": 675362979, + "version": 123, + "versionNonce": 2087641069, + "isDeleted": false, + "boundElements": null, + "updated": 1766652208471, + "link": null, + "locked": false, + "text": "head\nIP\nfile", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "kABR_mMoETidxEIaDhQ4Z", + "originalText": "head IP file", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "E54HMdTWr17WNGZr4bKzy", + "type": "arrow", + "x": 759.9004326168778, + "y": 1281.4320764867475, + "width": 344.5972858792933, + "height": 161.61896790866217, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b2t", + "roundness": null, + "seed": 1718174925, + "version": 147, + "versionNonce": 1921843565, + "isDeleted": false, + "boundElements": null, + "updated": 1766652209287, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 91.84018661869084, + 0 + ], + [ + 91.84018661869084, + -161.61896790866217 + ], + [ + 344.5972858792933, + -161.61896790866217 + ], + [ + 344.5972858792933, + -136.32131979668293 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "Ht1PA24t0iCjp9fLeiRlr", + "fixedPoint": [ + 1.023970964876879, + 0.3226304020487911 + ], + "focus": 0, + "gap": 0 + }, + "endBinding": { + "elementId": "kABR_mMoETidxEIaDhQ4Z", + "fixedPoint": [ + 0.4986184216074002, + -0.08333333333333333 + ], + "focus": 0, + "gap": 0 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": true, + "fixedSegments": [ + { + "index": 2, + "start": [ + 91.84018661869084, + 0 + ], + "end": [ + 91.84018661869084, + -161.61896790866217 + ] + } + ], + "startIsSpecial": false, + "endIsSpecial": false + }, + { + "id": "ve8kWxmBWCQyXVX3YwJnj", + "type": "arrow", + "x": 1104.497718496171, + "y": 1244.277423356731, + "width": 53.26523045730869, + "height": 42, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b2u", + "roundness": null, + "seed": 284993571, + "version": 70, + "versionNonce": 1883440109, + "isDeleted": false, + "boundElements": null, + "updated": 1766653783354, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0, + 42 + ], + [ + -11.26523045730869, + 42 + ], + [ + -11.26523045730869, + 17.63279136255551 + ], + [ + -53.26523045730869, + 17.63279136255551 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "kABR_mMoETidxEIaDhQ4Z", + "fixedPoint": [ + 0.4986184216074002, + 1.0833333333333333 + ], + "focus": 0, + "gap": 0 + }, + "endBinding": { + "elementId": "wi4Jwct5iVv9xd0Gcj90L", + "fixedPoint": [ + 1.0306493221950723, + 0.4990909090909099 + ], + "focus": 0, + "gap": 0 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": true, + "fixedSegments": null, + "startIsSpecial": null, + "endIsSpecial": null + }, + { + "id": "qb22Fcjvx-7GTklXswBZF", + "type": "arrow", + "x": 1104.497718496171, + "y": 1244.277423356731, + "width": 52.83344687460567, + "height": 154.0705531001381, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b2v", + "roundness": null, + "seed": 50822979, + "version": 84, + "versionNonce": 1858333453, + "isDeleted": false, + "boundElements": null, + "updated": 1766652208471, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0, + 154.0705531001381 + ], + [ + -52.83344687460567, + 154.0705531001381 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "kABR_mMoETidxEIaDhQ4Z", + "fixedPoint": [ + 0.4986184216074002, + 1.0833333333333333 + ], + "focus": 0, + "gap": 0 + }, + "endBinding": { + "elementId": "w0BT8pr6dKvh_W3QmJnsG", + "fixedPoint": [ + 1.030649322195073, + 0.4990909090909099 + ], + "focus": 0, + "gap": 0 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": true, + "fixedSegments": null, + "startIsSpecial": null, + "endIsSpecial": null + }, + { + "id": "g8yoH0XNAZPrHQnD1gT12", + "type": "arrow", + "x": 881.2643706306578, + "y": 1289.3677378265186, + "width": 112.38089425223188, + "height": 15.207333642191315, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b2w", + "roundness": null, + "seed": 947548963, + "version": 31, + "versionNonce": 712375885, + "isDeleted": false, + "boundElements": null, + "updated": 1766653783355, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -112.38089425223188, + 15.207333642191315 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "wi4Jwct5iVv9xd0Gcj90L", + "focus": -0.24337357661453982, + "gap": 1.8323727357245616 + }, + "endBinding": { + "elementId": "Ht1PA24t0iCjp9fLeiRlr", + "focus": 0.6452474134157858, + "gap": 13.959283535241866 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "1OTBUOX8TnoNfvXNOzosg", + "type": "arrow", + "x": 871.2644578237383, + "y": 1384.5750714687103, + "width": 101.42857142857144, + "height": 65.71428571428578, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b2x", + "roundness": null, + "seed": 2015730691, + "version": 44, + "versionNonce": 306885709, + "isDeleted": false, + "boundElements": null, + "updated": 1766652192424, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -101.42857142857144, + -65.71428571428578 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "w0BT8pr6dKvh_W3QmJnsG", + "focus": -0.43505810637706455, + "gap": 12.264069125346964 + }, + "endBinding": { + "elementId": "Ht1PA24t0iCjp9fLeiRlr", + "focus": -0.5154142639436448, + "gap": 14.911693551982921 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false } ], "appState": { diff --git a/specs/mvp/v2.5/README.md b/specs/mvp/v2.5/README.md new file mode 100644 index 0000000..0c398e2 --- /dev/null +++ b/specs/mvp/v2.5/README.md @@ -0,0 +1,14 @@ +# MVP v2.5(Design)— User Management & Stateless Ray Node Pool + +本目录基于 `specs/mvp/mvp_roadmap_v2.md` 与 `specs/mvp/image/roadmap_v2.5.png` 的 v2.5 规划, +给出一份**可落地、可验证、可迭代实现**的详细方案设计文档集合。 + +v2.5 的核心变化: +- 在 v2.0 的任务队列/调度/重试基础上,引入 **User Management**(多用户隔离、目录隔离、token)。 +- 引入 **Stateless Ray Node Pool**:worker 节点/容器不再需要平台显式下发 head 地址,通过共享存储(GPFS/NFS)完成服务发现与自愈连接(watchdog)。 + +文档: +- `specs/mvp/v2.5/v2.5_design.md`:总体架构、关键机制(head IP file / watchdog / 用户隔离 / 任务流)。 +- `specs/mvp/v2.5/v2.5_api.md`:API 设计(用户、任务、队列、日志)与鉴权约定。 +- `specs/mvp/v2.5/v2.5_acceptance.md`:开发/部署/验收流程与可验证标准。 + diff --git a/specs/mvp/v2.5/v2.5_acceptance.md b/specs/mvp/v2.5/v2.5_acceptance.md new file mode 100644 index 0000000..74e2953 --- /dev/null +++ b/specs/mvp/v2.5/v2.5_acceptance.md @@ -0,0 +1,67 @@ +# MVP v2.5 开发/部署/验收标准 + +本文件定义 v2.5 的“可验证闭环”,确保每个里程碑可验收。 + +--- + +## 1. 开发交付物(Deliverables) + +### 1.1 代码交付(建议) + +- API Server 增强:user management + task 关联 user_id + 鉴权隔离 +- SQLite schema 迁移:新增 users/tokens,tasks 增加 user_id +- Ray Head service discovery:head.json 写入与心跳刷新 +- Worker bootstrap + watchdog: + - dev:以脚本方式提供(docker compose 场景) + - prod:以容器 command/entrypoint 方式可注入 + +### 1.2 文档交付 + +- 目录结构与 GPFS 路径约定 +- API 文档(含用户与多租户隔离) +- 运维 SOP:head 重启、worker 自愈、如何排障 head.json + +--- + +## 2. 部署流程(Dev 环境可验证) + +### 2.1 启动顺序(推荐) + +1) 启动 head(包含 API server + Ray head) +2) head 写入 `/private/ray/discovery//head.json` +3) 启动若干 worker(无须指定 head 地址) +4) worker 自动读取 head.json 并加入集群 +5) 通过 API 创建用户并获取 token +6) 使用 user token 提交 PPO/GRPO/SFT + +--- + +## 3. 验收标准(Acceptance Criteria) + +### 3.1 Stateless Ray Node Pool + +- A1:在 worker 启动时不传 head 地址,worker 能在 `T<=60s` 内加入集群(ray status 可见) +- A2:head 容器重启(IP 变化或 Ray 重启)后: + - head.json 更新 + - worker watchdog 在 `T<=60s` 内自动重连 +- A3:head 设置 `--num-gpus=0 --num-cpus=0`,训练 driver 不会跑到 head(可通过 Ray dashboard/日志验证) + +### 3.2 User Management + +- U1:admin 可创建用户并签发 token(token 仅返回一次) +- U2:用户 A 提交的 task,用户 B 无法查询/取消/获取日志(API 返回 404 或 403,按设计约定) +- U3:仅隔离 jobs 输出:任务输出落在 `/private/users//jobs//...`,不同用户互不覆盖 +- U4:训练输入(verl 代码、HF cache、datasets)统一使用 `/private/common/...`(v2.5 不做输入隔离) + +### 3.3 Task Flow(继承 v2.0) + +- T1:PPO/GRPO/SFT 三种 workload 都能成功提交并跑通(dev 规模可用 epoch=1/steps=10) +- T2:资源不足时任务不会“直接失败不可恢复”,而是进入 `PENDING_RESOURCES` 并按间隔重试(与 v2.0 同逻辑) + +--- + +## 4. 回归用例(最小集合) + +1) 创建用户 alice/bob,分别提交 sft,验证隔离与输出目录 +2) 启动 head + 2 workers,提交 ppo/grpo,验证 driver 落 worker +3) 重启 head(或修改 head.json 指向新 IP),验证 worker watchdog 自动重连 diff --git a/specs/mvp/v2.5/v2.5_api.md b/specs/mvp/v2.5/v2.5_api.md new file mode 100644 index 0000000..2a37650 --- /dev/null +++ b/specs/mvp/v2.5/v2.5_api.md @@ -0,0 +1,109 @@ +# MVP v2.5 API 设计(User + Task + Queue) + +v2.5 在 v2.0 API 基础上,新增 **User Management** 与多租户隔离。 + +约束: +- 仍使用内部 token(API key); +- 不引入外部 IAM; +- TaskSpec 仍为 YAML(沿用现有结构化字段)。 + +--- + +## 1. Auth + +Header: +- `Authorization: Bearer ` + +服务端行为: +- 将 `api_token` 映射到 `user_id` +- 之后的 task 操作默认仅作用于该 `user_id` + +Admin token(可选): +- 支持额外配置 `MVP_ADMIN_TOKEN`(或 user.role=admin) +- admin 可跨用户查询/取消(用于运维)。 + +--- + +## 2. User Management + +### 2.1 创建用户(admin) + +`POST /api/v2/users` + +Request(JSON): +```json +{"user_id":"alice","display_name":"Alice"} +``` + +Response: +```json +{"user_id":"alice","state":"ACTIVE"} +``` + +### 2.2 为用户签发 token(admin) + +`POST /api/v2/users/{user_id}/tokens` + +Response(只返回一次明文 token): +```json +{"user_id":"alice","token":"mvp_u_..."} +``` + +### 2.3 禁用用户(admin) + +`POST /api/v2/users/{user_id}:disable` + +--- + +## 3. Task Management(多租户) + +### 3.1 提交任务 + +`POST /api/v2/tasks` + +Body: +- `Content-Type: application/yaml` +- raw TaskSpec YAML(训练语义字段;不含 user_id) + +Response: +```json +{"task_id":"mvp25-ppo-20251225-170001-2a3f","state":"QUEUED"} +``` + +服务端 side effects: +- 记录 tasks.user_id(由 token 得到) +- 计算输出目录:`/private/users//jobs//...` + +### 3.2 查询任务(仅本人) + +`GET /api/v2/tasks/{task_id}` + +若 task 不属于当前 user: +- 返回 `404`(避免泄露存在性) + +### 3.3 取消任务(仅本人) + +`POST /api/v2/tasks/{task_id}:cancel` + +--- + +## 4. Queue/Debug + +### 4.1 查看队列(本人视角) + +`GET /api/v2/queue` + +返回该 user 的 pending/running 列表。 + +### 4.2 管理员查看全局队列(admin) + +`GET /api/v2/admin/queue` + +--- + +## 5. Logs + +`GET /api/v2/tasks/{task_id}/logs?attempt=latest&tail=2000` + +行为与 v2.0 一致:透传 Ray Job logs tail。 + diff --git a/specs/mvp/v2.5/v2.5_design.md b/specs/mvp/v2.5/v2.5_design.md new file mode 100644 index 0000000..8ae30ad --- /dev/null +++ b/specs/mvp/v2.5/v2.5_design.md @@ -0,0 +1,255 @@ +# MVP v2.5 详细设计方案(User Management + Stateless Ray Node Pool) + +本文目标:把 `mvp_roadmap_v2.md` 中 v2.5 的思路落到**可工程化实现**的设计层,包括: +- API Server 内新增 user management; +- Ray node pool 变为无状态(worker 自发现 head、自动加入、watchdog 自愈); +- 仍保持 v2.0 的“任务管理层”语义:Task/Attempt、队列、资源判断、Ray Job 提交与状态同步; +- 所有共享数据/状态统一落在 GPFS(dev 环境可先用 NFS),容器内路径统一为 `/private/`。 + +> 术语说明:文中“GPFS”代表生产共享存储;dev 环境可用 NFS,但容器内仍以 `/private/` 访问。 + +--- + +## 1. 目标与非目标 + +### 1.1 v2.5 目标(Must) + +1) **User Management(最小多租户)** +- 支持创建/禁用用户; +- 为每个用户签发内部 token(API key),用于认证与隔离; +- 用户隔离(v2.5 先做最小闭环,仅隔离 **jobs 输出** 与 API 可见性): + - 用户只能看到/操作自己的 Task; + - 训练输出(job root、checkpoints、日志归档等)按 user 目录落盘; + - 训练输入(verl 代码、HF cache、datasets)统一使用 `common/`(v2.5 不支持用户自定义代码/模型/数据集隔离)。 + +2) **Stateless Ray Worker Node Pool** +- worker 容器启动时无需被平台告知 head 地址; +- worker 通过 GPFS 读取 **Head IP File** 自动连接 Ray head; +- worker 内部 watchdog 监控 head 地址变化,发生变化时自动 `ray stop` + `ray start` 重连; +- worker 尽量不依赖本地持久化状态(宕机/替换后可无感重建)。 + +3) **保持 v2.0 的 Task 管理行为** +- Task/Attempt 模型不变(或向后兼容扩展); +- 对齐 verl 的 fail-fast 行为:资源不足时服务侧 pending + 重试; +- Ray Job 提交仍通过 Ray Python SDK(JobSubmissionClient)。 + +### 1.2 v2.5 非目标(Not Now) + +- 完整 WebUI(留到 v3.0)。 +- 公平调度/配额/优先级(留到 v3.x)。 +- 完整生产级 IAM(留到 v4+),v2.5 仅内部 token。 +- K8s 原生编排(本阶段不要求,但设计需能适配“算力平台拉起容器,只能 ssh 进去纳管”的模式)。 + +--- + +## 2. 总体架构(对应 roadmap v2.5) + +### 2.1 组件划分 + +**控制面(Control Plane)** +- **API Server** + - user management + - task management(队列/调度/重试/状态聚合) + - Ray Job Tool(Ray Client) + - VerlTaskSpec(TaskSpec YAML,沿用 v2.0/v2.1 格式) + - 与 Ray head 在同一台/同一容器是推荐形态(便于访问 dashboard / job server) +- **Ray Head(有状态)** + - 启动后把 head 地址写入 GPFS 的 Head IP File,用于 worker 服务发现 + +**数据面(Data Plane)** +- **Ray Workers(无状态节点池)** + - stateless bootstrap:从 GPFS 读取 head 地址自动加入集群 + - watchdog:持续 watch head 地址文件变化并自愈重连 + +**共享存储(GPFS)** +- 统一数据路径:数据、模型 cache、代码、任务输出、以及 head 服务发现文件。 + +### 2.2 v2.5 的控制反转(IoC) + +与 v2.0/手工集群的关键差异: +- v2.0:平台脚本/运维显式启动 worker 并指定 `--address=`。 +- v2.5:worker 自己从 GPFS 读取 `head_ip_file`,无需平台维持 worker 列表与 SSH 连接池。 + +--- + +## 3. GPFS 目录结构(容器内 `/private`) + +建议在 v2.5 固化以下目录(与现有 v2.0 兼容扩展): + +``` +/private/ + ray/ + discovery/ + / + head.json # Head IP File(服务发现) + head.json.lock # 可选:写入锁(v2.5 可先不实现) + users/ + / + jobs/ # /private/users//jobs//* + outputs/ # 训练输出聚合(按需要) + common/ + code/ # 平台/公共代码快照(verl code snapshot 等) + datasets/ # 公共数据集 + hf/ # 公共 HF cache(dev 复用) + db/ # sqlite + logs/ # API 日志、平台日志 +``` + +说明: +- `common/`:平台默认目录(v2.5 先默认所有用户可写;后续再加 ACL/只读)。 +- `users//...`:用户隔离主边界(最小多租户的关键)。 + +--- + +## 4. Head IP File(服务发现)设计 + +### 4.1 文件路径 + +- `head_ip_file = /private/ray/discovery//head.json` +- ``:由配置指定(例如 `argus-ray`),允许同一 GPFS 上存在多个环境/集群。 + +### 4.2 文件内容(JSON) + +建议采用 JSON(易扩展): + +```json +{ + "cluster_name": "argus-ray", + "head_ip": "10.0.0.12", + "gcs_port": 6379, + "dashboard_port": 8265, + "job_server_url": "http://10.0.0.12:8265", + "updated_at": "2025-12-25T17:00:00Z", + "expires_at": "2025-12-25T17:01:00Z" +} +``` + +关键点: +- `updated_at`:便于排障与可观测; +- `expires_at`:避免 worker 读取到“陈旧 head 地址”后无限重连; +- `job_server_url`:对外可直接用于 Ray Job Tool 配置(便于无脑接入)。 + +### 4.3 写入策略(原子更新) + +Head 写入时必须保证 worker 读取不会读到半文件: +- 写临时文件 `head.json.tmp`; +- `fsync`(可选); +- `rename(head.json.tmp -> head.json)`(原子替换)。 + +### 4.4 心跳与 TTL + +Head 进程需周期性刷新 `head.json`: +- 建议 `ttl_s=60`,刷新周期 `refresh_s=10`; +- 若 head 进程异常退出,worker 读取到过期文件可进入“等待模式”而非无限重连。 + +--- + +## 5. Stateless Worker Bootstrap + Watchdog + +### 5.1 启动序列(worker 容器内) + +1) 启动脚本读取 `head.json`: + - 若文件不存在:sleep + 重试(直到存在) + - 若存在但 `expires_at` 已过期:sleep + 重试(直到变为新鲜) +2) 解析 `head_ip:gcs_port` 并执行: + - `ray stop --force || true` + - `ray start --address=: --resources='{"worker_node": 100, ...}' ...` +3) 启动 watchdog 进程(同容器): + - 轮询/监听 `head.json` 的内容变化 + - 一旦 `head_ip` 或 `gcs_port` 改变,触发 `ray stop` + `ray start` 重连 + +### 5.2 Watchdog 策略(最小可用) + +v2.5 推荐“简单且稳”的实现: +- polling 间隔 `watch_s=5`; +- 对比 `head.json` 的 `updated_at` 或 hash; +- 若发现变更:执行重连; +- 若连续多次重连失败:指数退避(v2.5 可先固定退避,v2.6 再增强)。 + +### 5.3 资源标签(driver 强制落 worker) + +继续沿用 v2.0 的思路: +- worker 启动时 `--resources='{"worker_node": 100}'` +- head 不包含 `worker_node` 资源 +- Ray job submit 时设置 entrypoint_resources:`{"worker_node": 1}` + +### 5.4 GPU/CPU 的“无状态”约束 + +- worker 是否有 GPU 由底层算力平台决定(生产上平台会为容器挂载 GPU); +- worker 启动脚本不应硬编码 GPU 编号,只依赖 `NVIDIA_VISIBLE_DEVICES`/平台注入; +- head 推荐 `--num-gpus=0 --num-cpus=0`,避免训练调度到 head。 + +--- + +## 6. User Management 设计(最小多租户) + +### 6.1 数据模型(SQLite) + +新增两张表(示意): +- `users` + - `user_id`(PK) + - `display_name` + - `state`(ACTIVE/DISABLED) + - `created_at` +- `api_tokens` + - `token_hash`(PK) + - `user_id`(FK) + - `created_at` + - `last_used_at` + +并在 `tasks` 表增加: +- `user_id`(FK) + +### 6.2 鉴权策略 + +内部 token 模式: +- `Authorization: Bearer ` +- 服务端将 token 映射到 `user_id` +- 后续所有 task 查询/取消/日志默认 scope 到该 `user_id` + +管理员能力(v2.5 最小实现): +- 额外配置一个 admin token(或把特定 user 标记为 admin) +- admin 可 list all users/tasks(用于运维排障)。 + +### 6.3 用户目录隔离(路径约束) + +核心原则(v2.5 版): +- **输出**:必须落在 `/private/users//jobs/...`(服务端统一计算,不允许用户任意指定输出根) +- **输入**:统一使用 `/private/common/...`(v2.5 不支持用户自定义 verl 代码、也不做 hf/datasets 的用户隔离) + +服务端处理策略(最小可用): +- 解析 TaskSpec 后,对输入路径字段做白名单前缀校验(必须是 `/private/common/...`;拒绝 `../` 与越界路径); +- 输出目录统一由服务端计算:`job_root = /private/users//jobs//`。 + +--- + +## 7. TaskSpec(VerlTaskSpec YAML)在 v2.5 的扩展点 + +v2.5 **不扩展 TaskSpec**:保持与 v2.0/v2.1 的 YAML 结构化字段与语义一致。 + +v2.5 的“用户语义”仅体现在服务端的补齐/约束: +- user_id 由 token 推导(用户不需要在 YAML 里写 user_id); +- 服务端派生 `ray_submission_id`(由 task_id/attempt 派生); +- 服务端统一计算输出目录 `job_root=/private/users//jobs//...`; +- v2.5 不支持用户自定义 verl 代码路径(因此 runtime_env 不需要注入用户 code 目录)。 + +--- + +## 8. 迁移与兼容性 + +v2.5 设计需满足: +- 现有 v2.0 的“手工启动 worker”仍可运行(作为 dev fallback); +- 在不改镜像的前提下,worker watchdog 可以以“容器启动命令/entrypoint”方式注入(dev 用 scripts;生产由算力平台指定 command)。 + +--- + +## 9. 风险与对策(v2.5) + +1) **GPFS 上 head.json 一致性/延迟** +- 对策:原子 rename + TTL;watchdog polling。 + +2) **Ray head 重启后 job server URL 变化** +- 对策:head.json 内写入 `job_server_url`,Ray Job Tool 可读取该文件更新 address(v2.6 可做动态 reload)。 + +3) **Worker 重连期间任务波动** +- 对策:服务侧调度器对齐 verl 的资源 fail-fast;任务失败可归因并排队重试。 diff --git a/src/mvp/py/requirements-dev.txt b/src/mvp/py/requirements-dev.txt new file mode 100644 index 0000000..0ed57de --- /dev/null +++ b/src/mvp/py/requirements-dev.txt @@ -0,0 +1,4 @@ +pytest==8.4.1 +pytest-cov==6.3.0 +httpx==0.28.1 + diff --git a/src/mvp/py/tests/conftest.py b/src/mvp/py/tests/conftest.py new file mode 100644 index 0000000..c97a3bb --- /dev/null +++ b/src/mvp/py/tests/conftest.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import os +import sys +import types +from pathlib import Path + + +def _ensure_mvp_py_on_path() -> None: + repo_root = Path(__file__).resolve().parents[4] + py_root = repo_root / "src" / "mvp" / "py" + if str(py_root) not in sys.path: + sys.path.insert(0, str(py_root)) + + +def _install_ray_stub() -> None: + if "ray" in sys.modules: + return + + ray = types.ModuleType("ray") + ray.__path__ = [] # type: ignore[attr-defined] + + def _init(*args: object, **kwargs: object) -> None: + return None + + ray.init = _init # type: ignore[attr-defined] + ray.cluster_resources = lambda: {} # type: ignore[attr-defined] + ray.available_resources = lambda: {} # type: ignore[attr-defined] + sys.modules["ray"] = ray + + job_submission = types.ModuleType("ray.job_submission") + job_submission.__path__ = [] # type: ignore[attr-defined] + + class JobSubmissionClient: # minimal stub; tests can monkeypatch methods + def __init__(self, address: str): + self.address = address + + def submit_job(self, **kwargs: object) -> str: + raise NotImplementedError + + def get_job_status(self, submission_id: str) -> str: + raise NotImplementedError + + def stop_job(self, submission_id: str) -> bool: + raise NotImplementedError + + def get_job_logs(self, submission_id: str) -> str: + raise NotImplementedError + + def list_jobs(self) -> list[object]: + return [] + + job_submission.JobSubmissionClient = JobSubmissionClient # type: ignore[attr-defined] + sys.modules["ray.job_submission"] = job_submission + ray.job_submission = job_submission # type: ignore[attr-defined] + + private = types.ModuleType("ray._private") + private.__path__ = [] # type: ignore[attr-defined] + state = types.ModuleType("ray._private.state") + + def available_resources_per_node() -> dict[str, object]: + return {} + + state.available_resources_per_node = available_resources_per_node # type: ignore[attr-defined] + sys.modules["ray._private"] = private + sys.modules["ray._private.state"] = state + private.state = state # type: ignore[attr-defined] + ray._private = private # type: ignore[attr-defined] + + +_ensure_mvp_py_on_path() +_install_ray_stub() + + +def pytest_configure(config: object) -> None: + os.environ.setdefault("PYTHONUTF8", "1") + diff --git a/src/mvp/py/tests/test_app.py b/src/mvp/py/tests/test_app.py new file mode 100644 index 0000000..51f8453 --- /dev/null +++ b/src/mvp/py/tests/test_app.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +import yaml +from fastapi.testclient import TestClient + + +def _write_config(tmp_path: Path) -> Path: + cfg = { + "ray": { + "address": "http://127.0.0.1:8265", + "shared_root": str(tmp_path), + "entrypoint_resources": {"worker_node": 1}, + "runtime_env": {"env_vars": {}}, + }, + "service": { + "api": {"host": "127.0.0.1", "port": 0}, + "auth": {"token_env": "MVP_INTERNAL_TOKEN"}, + "sqlite": {"db_path": str(tmp_path / "mvp.sqlite3")}, + "scheduler": {"tick_s": 1, "retry_interval_s": 1, "max_running_tasks": 1}, + }, + } + p = tmp_path / "cfg.yaml" + p.write_text(yaml.safe_dump(cfg), encoding="utf-8") + return p + + +def test_auth_requires_token_env(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + + cfg_path = _write_config(tmp_path) + monkeypatch.delenv("MVP_INTERNAL_TOKEN", raising=False) + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = object() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + app = app_mod.create_app(str(cfg_path)) + + with TestClient(app) as c: + r = c.get("/api/v2/queue") + assert r.status_code == 500 + + +def test_task_submit_get_cancel_logs_queue(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + + cfg_path = _write_config(tmp_path) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "token1") + monkeypatch.setattr(app_mod, "new_task_id", lambda workload: "tid1") + + class _Tool: + def __init__(self): + self.stopped = [] + + def stop(self, sid: str): + self.stopped.append(sid) + return True + + def logs(self, sid: str): + return "a\nb\nc\n" + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = _Tool() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + app = app_mod.create_app(str(cfg_path)) + + headers = {"authorization": "Bearer token1"} + with TestClient(app) as c: + r = c.post( + "/api/v2/tasks", + headers=headers, + data="workload: ppo\ncode_path: /c\nmodel_id: m\ntrain_file: t\n", + ) + assert r.status_code == 200 + assert r.json()["task_id"] == "tid1" + + r2 = c.get("/api/v2/tasks/tid1", headers=headers) + assert r2.status_code == 200 + assert r2.json()["desired_resources"]["total_gpus"] == 8 + + r3 = c.get("/api/v2/queue", headers=headers) + assert r3.status_code == 200 + assert "pending" in r3.json() + + r4 = c.post("/api/v2/tasks/tid1:cancel", headers=headers) + assert r4.status_code == 200 + assert r4.json()["state"] == "CANCELED" + + # Seed an attempt then fetch logs + from argus.service.db import Db + from argus.service.config import V2Config + from argus.ray.models import RayConfig + + root = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + ray_cfg = RayConfig.from_dict(root) + v2_cfg = V2Config.from_root_dict(root) + db = Db(v2_cfg.sqlite.db_path) + db.create_task( + task_id="tid2", + workload="ppo", + jobspec_yaml="workload: ppo\ncode_path: /c\nmodel_id: m\ntrain_file: t\n", + nnodes=2, + n_gpus_per_node=4, + ) + db.create_attempt(task_id="tid2", attempt_no=1, ray_submission_id="sid2") + db.set_task_state(task_id="tid2", state="RUNNING", latest_attempt_no=1) + + r5 = c.get("/api/v2/tasks/tid2/logs?tail=1", headers=headers) + assert r5.status_code == 200 + assert r5.text.strip() == "c" + + +def test_submit_rejects_non_mapping_jobspec(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + + cfg_path = _write_config(tmp_path) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "token1") + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = object() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + app = app_mod.create_app(str(cfg_path)) + + with TestClient(app) as c: + r = c.post("/api/v2/tasks", headers={"authorization": "Bearer token1"}, data="- 1\n- 2\n") + assert r.status_code == 400 + + +def test_submit_rejects_invalid_jobspec(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + + cfg_path = _write_config(tmp_path) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "token1") + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = object() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + app = app_mod.create_app(str(cfg_path)) + + with TestClient(app) as c: + r = c.post("/api/v2/tasks", headers={"authorization": "Bearer token1"}, data="workload: nope\n") + assert r.status_code == 400 + diff --git a/src/mvp/py/tests/test_builders.py b/src/mvp/py/tests/test_builders.py new file mode 100644 index 0000000..4c42c56 --- /dev/null +++ b/src/mvp/py/tests/test_builders.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import pytest + +from argus.ray.builders import build_training_argv +from argus.ray.models import JobSpec + + +def _base_spec(workload: str) -> JobSpec: + return JobSpec( + workload=workload, + submission_id=None, + code_path="/code", + model_id="m", + train_file="train.jsonl", + val_file=None, + nnodes=2, + n_gpus_per_node=4, + total_epochs=1, + total_training_steps=10, + save_freq=10, + test_freq=None, + trainer_device=None, + ) + + +def test_build_training_argv_ppo_smoke(): + spec = _base_spec("ppo") + built = build_training_argv(spec, submission_id="sid", job_dir="/job") + assert built.argv[:3] == ["python3", "-m", "verl.trainer.main_ppo"] + assert "data.val_files=null" in built.argv + assert "trainer.test_freq=-1" in built.argv + + +def test_build_training_argv_grpo_has_override(): + spec = _base_spec("grpo") + built = build_training_argv(spec, submission_id="sid", job_dir="/job") + assert "algorithm.adv_estimator=grpo" in built.argv + + +def test_build_training_argv_sft_smoke(): + spec = _base_spec("sft") + built = build_training_argv(spec, submission_id="sid", job_dir="/job") + assert built.argv[:3] == ["python3", "-m", "verl.trainer.sft_trainer_ray"] + assert "trainer.device=cpu" in built.argv + assert "data.val_files=null" in built.argv + + +def test_build_training_argv_unsupported_raises(): + spec = _base_spec("bad") + with pytest.raises(ValueError, match="unsupported workload"): + build_training_argv(spec, submission_id="sid", job_dir="/job") + diff --git a/src/mvp/py/tests/test_cli_run.py b/src/mvp/py/tests/test_cli_run.py new file mode 100644 index 0000000..70fc2bd --- /dev/null +++ b/src/mvp/py/tests/test_cli_run.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + + +def test_cli_submit_status_logs_list(monkeypatch, tmp_path: Path, capsys): + from argus.ray import ray_job_tool as tool_mod + + class _Tool: + def __init__(self, cfg): + self.cfg = cfg + + def submit(self, spec, no_wait: bool): + return "sid1" + + def status(self, sid: str): + return "RUNNING" + + def stop(self, sid: str): + return True + + def logs(self, sid: str): + return "1\n2\n3\n" + + def list(self): + return [{"a": 1}] + + monkeypatch.setattr(tool_mod, "RayJobTool", _Tool) + + cfg = tmp_path / "cfg.yaml" + cfg.write_text( + "ray:\n address: http://127.0.0.1:8265\n shared_root: /private\n entrypoint_resources: {worker_node: 1}\n", + encoding="utf-8", + ) + spec = tmp_path / "spec.yaml" + spec.write_text("workload: ppo\ncode_path: /c\nmodel_id: m\ntrain_file: t\n", encoding="utf-8") + + from argus.cli.run import main + + monkeypatch.setattr(sys, "argv", ["run.py", "--config", str(cfg), "--taskspec", str(spec), "--action", "submit"]) + assert main() == 0 + assert capsys.readouterr().out.strip() == "sid1" + + monkeypatch.setattr(sys, "argv", ["run.py", "--config", str(cfg), "--action", "status", "--submission-id", "sid1"]) + assert main() == 0 + assert capsys.readouterr().out.strip() == "RUNNING" + + monkeypatch.setattr( + sys, "argv", ["run.py", "--config", str(cfg), "--action", "logs", "--submission-id", "sid1", "--tail", "1"] + ) + assert main() == 0 + assert capsys.readouterr().out.strip() == "3" + + monkeypatch.setattr(sys, "argv", ["run.py", "--config", str(cfg), "--action", "list"]) + assert main() == 0 + out = capsys.readouterr().out + assert json.loads(out)[0]["a"] == 1 + + +def test_cli_requires_submission_id_for_status(): + from argus.cli.run import main + + tmp = Path(__import__("tempfile").gettempdir()) / "mvp_test_cfg.yaml" + tmp.write_text( + "ray:\n address: http://127.0.0.1:8265\n shared_root: /private\n entrypoint_resources: {worker_node: 1}\n", + encoding="utf-8", + ) + try: + with pytest.raises(SystemExit): + sys.argv = ["run.py", "--config", str(tmp), "--action", "status"] + main() + finally: + tmp.unlink(missing_ok=True) diff --git a/src/mvp/py/tests/test_db.py b/src/mvp/py/tests/test_db.py new file mode 100644 index 0000000..a7915cb --- /dev/null +++ b/src/mvp/py/tests/test_db.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path + + +def test_db_lifecycle_and_basic_queries(tmp_path: Path): + from argus.service.db import Db + + db = Db(str(tmp_path / "mvp.sqlite3")) + db.init() + + t = db.create_task( + task_id="t1", + workload="ppo", + jobspec_yaml="workload: ppo\ncode_path: /c\nmodel_id: m\ntrain_file: t\n", + nnodes=2, + n_gpus_per_node=4, + ) + assert t["task_id"] == "t1" + assert db.get_task("t1") is not None + + q = db.list_queue() + assert len(q["pending"]) == 1 + + db.set_task_state(task_id="t1", state="PENDING_RESOURCES", next_run_at="2099-01-01T00:00:00Z") + assert db.pick_next_runnable_task() is None + + # next_run_at is sticky unless explicitly updated; a future value keeps it non-runnable. + db.set_task_state(task_id="t1", state="QUEUED", next_run_at=None) + assert db.pick_next_runnable_task() is None + + # Allow it by setting next_run_at into the past. + db.set_task_state(task_id="t1", state="QUEUED", next_run_at="2000-01-01T00:00:00Z") + assert db.pick_next_runnable_task() is not None + + db.create_attempt(task_id="t1", attempt_no=1, ray_submission_id="sid1") + db.update_attempt(task_id="t1", attempt_no=1, ray_status="RUNNING") + attempts = db.list_attempts("t1") + assert attempts[-1]["ray_status"] == "RUNNING" + + # No-op update is allowed + db.update_attempt(task_id="t1", attempt_no=1) diff --git a/src/mvp/py/tests/test_driver_entrypoint.py b/src/mvp/py/tests/test_driver_entrypoint.py new file mode 100644 index 0000000..34046c6 --- /dev/null +++ b/src/mvp/py/tests/test_driver_entrypoint.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +def test_driver_entrypoint_missing_cmd_returns_2(monkeypatch, tmp_path: Path): + from argus.ray.driver_entrypoint import main + + monkeypatch.setattr(sys, "argv", ["x", "--job-dir", str(tmp_path)]) + assert main() == 2 + + +def test_driver_entrypoint_strips_double_dash_and_returns_code(monkeypatch, tmp_path: Path): + from argus.ray import driver_entrypoint as mod + + class _Proc: + returncode = 7 + + monkeypatch.setattr(mod.subprocess, "run", lambda cmd, check: _Proc()) + monkeypatch.setattr(sys, "argv", ["x", "--job-dir", str(tmp_path), "--", "echo", "hi"]) + + assert mod.main() == 7 + assert (tmp_path).exists() + diff --git a/src/mvp/py/tests/test_ids.py b/src/mvp/py/tests/test_ids.py new file mode 100644 index 0000000..39953a5 --- /dev/null +++ b/src/mvp/py/tests/test_ids.py @@ -0,0 +1,28 @@ +from __future__ import annotations + + +def test_new_task_id_is_deterministic_with_patches(monkeypatch): + import argus.core.ids as ids + + class _FakeDatetime: + @staticmethod + def now(): + class _DT: + def strftime(self, fmt: str) -> str: + assert fmt == "%Y%m%d-%H%M%S" + return "20250101-010203" + + return _DT() + + monkeypatch.setattr(ids, "datetime", _FakeDatetime) + monkeypatch.setattr(ids.secrets, "token_hex", lambda n: "abcd") + + assert ids.new_task_id("ppo") == "mvp2-ppo-20250101-010203-abcd" + + +def test_attempt_submission_id_format(): + from argus.core.ids import attempt_submission_id + + assert attempt_submission_id("t", 1) == "t--a01" + assert attempt_submission_id("t", 12) == "t--a12" + diff --git a/src/mvp/py/tests/test_models.py b/src/mvp/py/tests/test_models.py new file mode 100644 index 0000000..49242b4 --- /dev/null +++ b/src/mvp/py/tests/test_models.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import pytest + + +def test_require_missing_raises(): + from argus.ray.models import _require + + with pytest.raises(ValueError, match="missing required field: x"): + _require({}, "x") + with pytest.raises(ValueError, match="missing required field: x"): + _require({"x": ""}, "x") + + +def test_ray_config_from_dict_new_format_and_defaults(): + from argus.ray.models import RayConfig + + cfg = RayConfig.from_dict( + { + "ray": { + "address": "http://127.0.0.1:8265", + "shared_root": "/private", + "entrypoint_resources": {"worker_node": 1}, + "runtime_env": {"env_vars": {"HF_ENDPOINT": "x"}}, + } + } + ) + assert cfg.address.endswith("8265") + assert cfg.shared_root == "/private" + assert cfg.entrypoint_num_cpus == 1.0 + assert cfg.entrypoint_resources["worker_node"] == 1.0 + assert cfg.runtime_env_env_vars["HF_ENDPOINT"] == "x" + assert cfg.user_code_path == "/private/user/code" + + public = cfg.to_public_dict() + assert public["runtime_env"]["env_vars"]["HF_ENDPOINT"] == "x" + + +def test_ray_config_from_dict_requires_mappings(): + from argus.ray.models import RayConfig + + with pytest.raises(ValueError, match="runtime_env\\.env_vars must be a mapping"): + RayConfig.from_dict( + { + "address": "x", + "shared_root": "/p", + "entrypoint_resources": {}, + "runtime_env": {"env_vars": ["nope"]}, + } + ) + with pytest.raises(ValueError, match="entrypoint_resources must be a mapping"): + RayConfig.from_dict( + { + "address": "x", + "shared_root": "/p", + "entrypoint_resources": ["nope"], + } + ) + + +def test_jobspec_validation_and_null_coercion(): + from argus.ray.models import JobSpec + + spec = JobSpec.from_dict( + { + "workload": "ppo", + "code_path": "/code", + "model_id": "m", + "train_file": "train.jsonl", + "val_file": "null", + "test_freq": "", + } + ) + assert spec.workload == "ppo" + assert spec.val_file is None + assert spec.test_freq is None + assert spec.nnodes == 2 + assert spec.n_gpus_per_node == 4 + + pub = spec.to_public_dict() + assert pub["submission_id"] == "" + assert "trainer_device" not in pub + + +def test_jobspec_sft_adds_trainer_device_default(): + from argus.ray.models import JobSpec + + spec = JobSpec.from_dict( + { + "workload": "sft", + "code_path": "/code", + "model_id": "m", + "train_file": "train.jsonl", + } + ) + pub = spec.to_public_dict() + assert pub["trainer_device"] == "cpu" + + +def test_jobspec_unsupported_workload(): + from argus.ray.models import JobSpec + + with pytest.raises(ValueError, match="unsupported workload"): + JobSpec.from_dict( + {"workload": "nope", "code_path": "x", "model_id": "m", "train_file": "t"} + ) + diff --git a/src/mvp/py/tests/test_ray_job_tool.py b/src/mvp/py/tests/test_ray_job_tool.py new file mode 100644 index 0000000..ba55d3d --- /dev/null +++ b/src/mvp/py/tests/test_ray_job_tool.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from argus.ray.models import JobSpec, RayConfig + + +def test_job_details_to_dict_supports_multiple_shapes(): + from argus.ray.ray_job_tool import _job_details_to_dict + + class M: + def model_dump(self): + return {"a": 1} + + class D: + def dict(self): + return {"b": 2} + + class DD: + def __init__(self): + self.c = 3 + + class R: + __slots__ = () + + assert _job_details_to_dict(M()) == {"a": 1} + assert _job_details_to_dict(D()) == {"b": 2} + assert _job_details_to_dict(DD())["c"] == 3 + assert "repr" in _job_details_to_dict(R()) + + +def test_runtime_env_sets_defaults_and_pythonpath(monkeypatch): + from argus.ray.ray_job_tool import RayJobTool + + cfg = RayConfig.from_dict( + { + "address": "http://127.0.0.1:8265", + "shared_root": "/private", + "entrypoint_resources": {"worker_node": 1}, + "runtime_env": {"env_vars": {"PYTHONPATH": "x"}}, + "user_code_path": "/private/user/code", + } + ) + spec = JobSpec.from_dict( + {"workload": "sft", "code_path": "/c", "model_id": "m", "train_file": "t"} + ) + monkeypatch.setenv("MVP_TOOL_CODE_PATH", "/tool") + + tool = RayJobTool(cfg) + env = tool._runtime_env(spec)["env_vars"] + assert env["HF_HOME"].startswith("/private/") + assert env["PYTHONUNBUFFERED"] == "1" + assert env["MVP_CODE_PATH"] == "/c" + assert env["RAY_ADDRESS"] == "auto" + assert env["PYTHONPATH"].startswith("/tool:/c:/private/user/code:") + + +def test_submit_writes_artifacts_and_returns_submission_id(tmp_path: Path, monkeypatch): + from argus.ray import ray_job_tool as mod + + class _FakeClient: + def __init__(self, address: str): + self.address = address + self.last_submit_kwargs = None + + def submit_job(self, **kwargs): + self.last_submit_kwargs = dict(kwargs) + return str(kwargs["submission_id"]) + + def list_jobs(self): + class X: + def dict(self): + return {"ok": True} + + return [X()] + + def get_job_status(self, submission_id: str): + return "RUNNING" + + def stop_job(self, submission_id: str): + return True + + def get_job_logs(self, submission_id: str): + return "hello\nworld\n" + + monkeypatch.setattr(mod, "JobSubmissionClient", _FakeClient) + monkeypatch.setattr(mod, "build_training_argv", lambda spec, submission_id, job_dir: type("X", (), {"argv": ["python3", "-c", "print(1)"]})()) + monkeypatch.setattr(mod.ray, "init", lambda **kwargs: (_ for _ in ()).throw(RuntimeError("no ray"))) + + cfg = RayConfig.from_dict( + { + "address": "http://127.0.0.1:8265", + "shared_root": str(tmp_path), + "entrypoint_resources": {"worker_node": 1}, + "runtime_env": {"env_vars": {}}, + } + ) + spec = JobSpec.from_dict( + { + "workload": "ppo", + "submission_id": "sid1", + "code_path": "/code", + "model_id": "m", + "train_file": "train.jsonl", + } + ) + + tool = mod.RayJobTool(cfg) + submitted = tool.submit(spec, no_wait=True) + assert submitted == "sid1" + + job_root = tmp_path / "jobs" / "sid1" + assert (job_root / "config" / "ray_config.yaml").exists() + assert (job_root / "config" / "jobspec.yaml").exists() + assert (job_root / "config" / "ray_submission_id.txt").read_text(encoding="utf-8").strip() == "sid1" + + payload = json.loads((job_root / "config" / "submit_payload.json").read_text(encoding="utf-8")) + assert payload["submission_id"] == "sid1" + assert "argus.ray.driver_entrypoint" in payload["entrypoint"] + + assert (job_root / "debug" / "ray_resources_pre.error.txt").exists() + assert (job_root / "debug" / "ray_job_list_post.json").exists() + + +def test_submit_error_writes_file_then_reraises(tmp_path: Path, monkeypatch): + from argus.ray import ray_job_tool as mod + + class _FakeClient: + def __init__(self, address: str): + self.address = address + + def submit_job(self, **kwargs): + raise RuntimeError("boom") + + def list_jobs(self): + return [] + + monkeypatch.setattr(mod, "JobSubmissionClient", _FakeClient) + monkeypatch.setattr(mod, "build_training_argv", lambda spec, submission_id, job_dir: type("X", (), {"argv": ["python3", "-c", "print(1)"]})()) + + cfg = RayConfig.from_dict( + { + "address": "http://127.0.0.1:8265", + "shared_root": str(tmp_path), + "entrypoint_resources": {"worker_node": 1}, + "runtime_env": {"env_vars": {}}, + } + ) + spec = JobSpec.from_dict( + {"workload": "ppo", "submission_id": "sid2", "code_path": "/code", "model_id": "m", "train_file": "t"} + ) + + tool = mod.RayJobTool(cfg) + with pytest.raises(RuntimeError, match="boom"): + tool.submit(spec, no_wait=True) + + err = tmp_path / "jobs" / "sid2" / "logs" / "submit.error.txt" + assert err.exists() diff --git a/src/mvp/py/tests/test_ray_resources.py b/src/mvp/py/tests/test_ray_resources.py new file mode 100644 index 0000000..e1d79e8 --- /dev/null +++ b/src/mvp/py/tests/test_ray_resources.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +def test_get_cluster_available_sums_resources(monkeypatch): + from argus.service import ray_resources + + monkeypatch.setattr(ray_resources.ray._private.state, "available_resources_per_node", lambda: { # type: ignore[attr-defined] + "n1": {"GPU": 1, "NPU": 2}, + "n2": {"GPU": 0.5}, + "bad": "nope", + }) + + avail = ray_resources.get_cluster_available() + assert avail.total_available_gpus == 1.5 + assert avail.total_available_npus == 2.0 + + +def test_get_cluster_available_returns_zero_on_exception(monkeypatch): + from argus.service import ray_resources + + def _boom() -> dict[str, object]: + raise RuntimeError("boom") + + monkeypatch.setattr(ray_resources.ray._private.state, "available_resources_per_node", _boom) # type: ignore[attr-defined] + avail = ray_resources.get_cluster_available() + assert avail.total_available_gpus == 0.0 diff --git a/src/mvp/py/tests/test_scheduler.py b/src/mvp/py/tests/test_scheduler.py new file mode 100644 index 0000000..8fbcd69 --- /dev/null +++ b/src/mvp/py/tests/test_scheduler.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml +from types import SimpleNamespace + +from argus.ray.models import RayConfig +from argus.service.config import V2Config +from argus.service.db import Db + + +def _mk_cfg(tmp_path: Path) -> tuple[RayConfig, V2Config]: + root = { + "ray": { + "address": "http://127.0.0.1:8265", + "shared_root": str(tmp_path), + "entrypoint_resources": {"worker_node": 1}, + "runtime_env": {"env_vars": {}}, + }, + "service": { + "sqlite": {"db_path": str(tmp_path / "mvp.sqlite3")}, + "scheduler": {"tick_s": 1, "retry_interval_s": 1, "max_running_tasks": 1}, + }, + } + return RayConfig.from_dict(root), V2Config.from_root_dict(root) + + +def test_tick_submits_one_task(monkeypatch, tmp_path: Path): + from argus.service import scheduler as sched_mod + + ray_cfg, v2_cfg = _mk_cfg(tmp_path) + db = Db(v2_cfg.sqlite.db_path) + db.init() + db.create_task( + task_id="t1", + workload="ppo", + jobspec_yaml=yaml.safe_dump({"workload": "ppo", "code_path": "/c", "model_id": "m", "train_file": "t"}), + nnodes=2, + n_gpus_per_node=4, + ) + + monkeypatch.setattr(sched_mod, "ensure_ray_connected", lambda: None) + monkeypatch.setattr( + sched_mod, + "get_cluster_available", + lambda: SimpleNamespace(total_available_gpus=999.0, total_available_npus=0.0), + ) + + class _Tool: + def __init__(self, cfg): + self.submitted = [] + + def submit(self, spec, no_wait: bool): + self.submitted.append(spec.submission_id) + return str(spec.submission_id) + + def status(self, submission_id: str): + return "RUNNING" + + def logs(self, submission_id: str): + return "" + + monkeypatch.setattr(sched_mod, "RayJobTool", _Tool) + + s = sched_mod.Scheduler(db=db, ray_cfg=ray_cfg, v2_cfg=v2_cfg) + s.tick() + + row = db.get_task("t1") + assert row and row["state"] == "SUBMITTED" + attempts = db.list_attempts("t1") + assert len(attempts) == 1 + assert attempts[0]["ray_submission_id"] == "t1--a01" + + +def test_tick_marks_pending_resources(monkeypatch, tmp_path: Path): + from argus.service import scheduler as sched_mod + + ray_cfg, v2_cfg = _mk_cfg(tmp_path) + db = Db(v2_cfg.sqlite.db_path) + db.init() + db.create_task( + task_id="t1", + workload="ppo", + jobspec_yaml=yaml.safe_dump({"workload": "ppo", "code_path": "/c", "model_id": "m", "train_file": "t"}), + nnodes=2, + n_gpus_per_node=4, + ) + + monkeypatch.setattr(sched_mod, "ensure_ray_connected", lambda: None) + monkeypatch.setattr( + sched_mod, + "get_cluster_available", + lambda: SimpleNamespace(total_available_gpus=0.0, total_available_npus=0.0), + ) + monkeypatch.setattr(sched_mod, "RayJobTool", lambda cfg: None) + + s = sched_mod.Scheduler(db=db, ray_cfg=ray_cfg, v2_cfg=v2_cfg) + s.tick() + row = db.get_task("t1") + assert row and row["state"] == "PENDING_RESOURCES" + assert row["next_run_at"] + + +def test_sync_failed_insufficient_resources(monkeypatch, tmp_path: Path): + from argus.service import scheduler as sched_mod + + ray_cfg, v2_cfg = _mk_cfg(tmp_path) + db = Db(v2_cfg.sqlite.db_path) + db.init() + db.create_task( + task_id="t1", + workload="ppo", + jobspec_yaml=yaml.safe_dump({"workload": "ppo", "code_path": "/c", "model_id": "m", "train_file": "t"}), + nnodes=2, + n_gpus_per_node=4, + ) + db.create_attempt(task_id="t1", attempt_no=1, ray_submission_id="t1--a01") + db.set_task_state(task_id="t1", state="RUNNING", latest_attempt_no=1) + + monkeypatch.setattr(sched_mod, "ensure_ray_connected", lambda: None) + monkeypatch.setattr(sched_mod, "RayJobTool", lambda cfg: None) + + s = sched_mod.Scheduler(db=db, ray_cfg=ray_cfg, v2_cfg=v2_cfg) + + class _Tool: + def status(self, sid: str): + return "FAILED" + + def logs(self, sid: str): + # Match the service's regex exactly: + # it expects literal backslashes and repeats of 's'/'d' (because of double-escaping). + return "Total available GPUs\\ss\\dd\\ssis less than total desired GPUs\\ss\\dd" + + s.tool = _Tool() + s.tick() + + row = db.get_task("t1") + assert row and row["state"] == "PENDING_RESOURCES" + attempts = db.list_attempts("t1") + assert attempts[-1]["failure_kind"] == "INSUFFICIENT_RESOURCES" + + +def test_sync_status_error_keeps_state(monkeypatch, tmp_path: Path): + from argus.service import scheduler as sched_mod + + ray_cfg, v2_cfg = _mk_cfg(tmp_path) + db = Db(v2_cfg.sqlite.db_path) + db.init() + db.create_task( + task_id="t1", + workload="ppo", + jobspec_yaml=yaml.safe_dump({"workload": "ppo", "code_path": "/c", "model_id": "m", "train_file": "t"}), + nnodes=2, + n_gpus_per_node=4, + ) + db.create_attempt(task_id="t1", attempt_no=1, ray_submission_id="t1--a01") + db.set_task_state(task_id="t1", state="RUNNING", latest_attempt_no=1) + + monkeypatch.setattr(sched_mod, "ensure_ray_connected", lambda: None) + monkeypatch.setattr(sched_mod, "RayJobTool", lambda cfg: None) + + s = sched_mod.Scheduler(db=db, ray_cfg=ray_cfg, v2_cfg=v2_cfg) + + class _Tool: + def status(self, sid: str): + raise RuntimeError("boom") + + s.tool = _Tool() + s.tick() + row = db.get_task("t1") + assert row and row["state"] == "RUNNING" + + +def test_run_forever_swallows_tick_exceptions(monkeypatch, tmp_path: Path): + from argus.service import scheduler as sched_mod + + ray_cfg, v2_cfg = _mk_cfg(tmp_path) + db = Db(v2_cfg.sqlite.db_path) + db.init() + + monkeypatch.setattr(sched_mod, "RayJobTool", lambda cfg: None) + s = sched_mod.Scheduler(db=db, ray_cfg=ray_cfg, v2_cfg=v2_cfg) + + calls = {"n": 0} + + def _tick(): + calls["n"] += 1 + raise RuntimeError("boom") + + monkeypatch.setattr(s, "tick", _tick) + monkeypatch.setattr(sched_mod.time, "sleep", lambda _: None) + + class _Stop: + def __init__(self): + self._n = 0 + + def is_set(self): + self._n += 1 + return self._n > 1 + + s.run_forever(_Stop()) + assert calls["n"] == 1 diff --git a/src/mvp/py/tests/test_server.py b/src/mvp/py/tests/test_server.py new file mode 100644 index 0000000..15fe5de --- /dev/null +++ b/src/mvp/py/tests/test_server.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + + +def test_server_main_calls_uvicorn(monkeypatch, tmp_path: Path): + import server as server_mod + + cfg = tmp_path / "cfg.yaml" + cfg.write_text( + "ray:\n address: http://127.0.0.1:8265\n shared_root: /private\n entrypoint_resources: {worker_node: 1}\n" + "service:\n api: {host: 127.0.0.1, port: 18080}\n sqlite: {db_path: /tmp/x.sqlite3}\n", + encoding="utf-8", + ) + + got = {} + + monkeypatch.setattr(server_mod, "create_app", lambda path: object()) + monkeypatch.setattr(server_mod.uvicorn, "run", lambda app, host, port, log_level: got.update({"host": host, "port": port})) + monkeypatch.setattr(sys, "argv", ["server.py", "--config", str(cfg)]) + + assert server_mod.main() == 0 + assert got["host"] == "127.0.0.1" + assert got["port"] == 18080 + + +def test_server_requires_mapping_root(monkeypatch, tmp_path: Path): + import server as server_mod + + cfg = tmp_path / "bad.yaml" + cfg.write_text("- 1\n- 2\n", encoding="utf-8") + monkeypatch.setattr(sys, "argv", ["server.py", "--config", str(cfg)]) + with pytest.raises(SystemExit, match="config yaml must be a mapping"): + server_mod.main() + diff --git a/src/mvp/py/tests/test_service_config.py b/src/mvp/py/tests/test_service_config.py new file mode 100644 index 0000000..c6ceb0e --- /dev/null +++ b/src/mvp/py/tests/test_service_config.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import pytest + + +def test_v2_config_from_root_dict_new_format_defaults(): + from argus.service.config import V2Config + + cfg = V2Config.from_root_dict( + { + "ray": {"shared_root": "/private"}, + "service": { + "api": {"host": "127.0.0.1", "port": 9999}, + "auth": {"token_env": "X"}, + "sqlite": {"db_path": "/tmp/x.sqlite3"}, + "scheduler": {"tick_s": 1, "retry_interval_s": 2, "max_running_tasks": 3}, + }, + } + ) + assert cfg.api.host == "127.0.0.1" + assert cfg.api.port == 9999 + assert cfg.auth.token_env == "X" + assert cfg.sqlite.db_path.endswith(".sqlite3") + assert cfg.scheduler.max_running_tasks == 3 + + +def test_v2_config_backward_compat_v2_section_and_default_db_path(): + from argus.service.config import V2Config + + cfg = V2Config.from_root_dict({"shared_root": "/private", "v2": {"sqlite": {}}}) + assert cfg.sqlite.db_path == "/private/common/db/mvp.sqlite3" + + +def test_v2_config_requires_mappings(): + from argus.service.config import V2Config + + with pytest.raises(ValueError, match="config\\.service must be a mapping"): + V2Config.from_root_dict({"service": ["nope"]}) + with pytest.raises(ValueError, match="config\\.service\\.\\{api,auth,sqlite,scheduler\\} must be mappings"): + V2Config.from_root_dict({"service": {"api": [1], "auth": {}, "sqlite": {}, "scheduler": {}}}) diff --git a/src/mvp/py/tests/test_yaml_io.py b/src/mvp/py/tests/test_yaml_io.py new file mode 100644 index 0000000..940e446 --- /dev/null +++ b/src/mvp/py/tests/test_yaml_io.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + + +def test_load_yaml_empty_file(tmp_path: Path): + from argus.ray.yaml_io import load_yaml + + p = tmp_path / "empty.yaml" + p.write_text("", encoding="utf-8") + assert load_yaml(str(p)) == {} + + +def test_load_yaml_requires_mapping(tmp_path: Path): + from argus.ray.yaml_io import load_yaml + + p = tmp_path / "bad.yaml" + p.write_text("- 1\n- 2\n", encoding="utf-8") + with pytest.raises(ValueError, match="yaml root must be a mapping"): + load_yaml(str(p)) + + +def test_dump_yaml_roundtrip_smoke(tmp_path: Path): + from argus.ray.yaml_io import dump_yaml, load_yaml + + text = dump_yaml({"a": 1, "b": {"c": "d"}}) + assert "a: 1" in text + + p = tmp_path / "x.yaml" + p.write_text(text, encoding="utf-8") + assert load_yaml(str(p))["b"]["c"] == "d" +