Skip to content

Commit 47eac75

Browse files
dotsdlianmkenney
andauthored
Strategist service initial implementation, with help from Claude Code. (#421)
* Strategist service initial implementation, with help from Claude Code. * Strategist refinements, with Claude's help * Working through remaining bits of Strategist._execute_strategy * Added initial Strategy guide to docs * Completed StrategistService with API points. * Working on Strategist tests * Added connection closing to Neo4jStore, StrategistService * Strategist tests now all passing * Working on remaining test failures in interface API and AlchemiscaleClient. * Fixed API validation error test * Black! * Adjustments to api tests for strategies, added unit tests for StrategyState * datetime module to class fixes * Add stratocaster to conda envs * Remove outdated import * Changes from @ianmkenney review * Black! * More fixes from @ianmkenney review * Refactored long StrategistService._execute_strategy method to be more readable * Black! * Fix and simplify strategist example config; adjust CLI accordingly * DummyStrategy now terminates after enough results accumulate * Black! * More test fixes from @ianmkenney review * Strategy guide incremental changes * Update docs/strategy_guide.rst Co-authored-by: Ian Kenney <ianmichaelkenney@gmail.com> * Reorganized user guide to allow expansion with Strategy guide * Doc fixes, plus rewrite of Strategy user guide doc * Fix sphinx warnings * More doc formatting fixes * Added top matter to user guide index * Added news item --------- Co-authored-by: Ian Kenney <ianmichaelkenney@gmail.com>
1 parent b769b3f commit 47eac75

40 files changed

Lines changed: 3191 additions & 323 deletions

alchemiscale/base/client.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,7 @@
2424
from gufe.tokenization import GufeTokenizable, JSON_HANDLER
2525

2626
from ..models import ScopedKey
27-
28-
29-
def json_to_gufe(jsondata):
30-
return GufeTokenizable.from_dict(json.loads(jsondata, cls=JSON_HANDLER.decoder))
27+
from ..compression import json_to_gufe
3128

3229

3330
class AlchemiscaleBaseClientError(Exception):

alchemiscale/cli.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,40 @@ def scope(func):
516516
return scope(func)
517517

518518

519+
@cli.command(help="Start the strategist service.")
520+
@click.option(
521+
"--config-file",
522+
type=click.File("r"),
523+
help="YAML configuration file for the strategist service.",
524+
required=True,
525+
)
526+
def strategist(config_file):
527+
"""Start the strategist service for executing strategies on networks."""
528+
from alchemiscale.models import Scope
529+
from alchemiscale.strategist.service import StrategistService
530+
from alchemiscale.strategist.settings import StrategistSettings
531+
532+
params = yaml.safe_load(config_file)
533+
534+
if "scopes" in params:
535+
params["scopes"] = [Scope.from_str(scope) for scope in params["scopes"]]
536+
537+
service = StrategistService(StrategistSettings(**params))
538+
539+
# add signal handling
540+
for signame in {"SIGHUP", "SIGINT", "SIGTERM"}:
541+
542+
def stop(*args, **kwargs):
543+
service.stop()
544+
545+
signal.signal(getattr(signal, signame), stop)
546+
547+
try:
548+
service.start()
549+
except KeyboardInterrupt:
550+
pass
551+
552+
519553
@cli.group(help="Subcommands for managing identities")
520554
def identity(): ...
521555

alchemiscale/compression.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
import zstandard as zstd
44

55

6+
def json_to_gufe(jsondata):
7+
return GufeTokenizable.from_dict(json.loads(jsondata, cls=JSON_HANDLER.decoder))
8+
9+
610
def compress_keyed_chain_zstd(keyed_chain: list[tuple[str, dict]]) -> bytes:
711
"""Compress a keyed chain using zstandard compression.
812

alchemiscale/interface/api.py

Lines changed: 144 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import json
1414
from gufe.tokenization import JSON_HANDLER, KeyedChain
15+
from pydantic import ValidationError
1516

1617
from ..base.api import (
1718
GufeJSONResponse,
@@ -30,7 +31,7 @@
3031
from ..settings import get_base_api_settings
3132
from ..storage.statestore import Neo4jStore
3233
from ..storage.objectstore import S3ObjectStore
33-
from ..storage.models import TaskStatusEnum
34+
from ..storage.models import TaskStatusEnum, StrategyState
3435
from ..models import Scope, ScopedKey
3536
from ..security.models import TokenData, CredentialedUserIdentity
3637

@@ -393,10 +394,6 @@ def get_chemicalsystem(
393394
### compute
394395

395396

396-
@router.post("/networks/{scoped_key}/strategy")
397-
def set_strategy(scoped_key: str, *, strategy: dict = Body(...), scope: Scope): ...
398-
399-
400397
@router.post("/transformations/{transformation_scoped_key}/tasks")
401398
def create_tasks(
402399
transformation_scoped_key,
@@ -1180,6 +1177,148 @@ def get_task_failures(
11801177
return [str(sk) for sk in n4js.get_task_failures(sk)]
11811178

11821179

1180+
### strategies
1181+
1182+
1183+
@router.post("/networks/{network_scoped_key}/strategy")
1184+
async def set_network_strategy(
1185+
network_scoped_key,
1186+
*,
1187+
request: Request,
1188+
n4js: Neo4jStore = Depends(get_n4js_depends),
1189+
token: TokenData = Depends(get_token_data_depends),
1190+
):
1191+
"""Set a Strategy for the given AlchemicalNetwork.
1192+
1193+
Expected request body:
1194+
{
1195+
"strategy": {...}, // GUFE strategy object, or null to remove
1196+
"max_tasks_per_transformation": 3,
1197+
"task_scaling": "exponential",
1198+
"mode": "partial",
1199+
"sleep_interval": 3600
1200+
}
1201+
"""
1202+
sk = ScopedKey.from_str(network_scoped_key)
1203+
validate_scopes(sk.scope, token)
1204+
1205+
# Handle request body with custom JSON decoder for GUFE objects
1206+
body = await request.body()
1207+
body_ = json.loads(body.decode("utf-8"), cls=JSON_HANDLER.decoder)
1208+
1209+
try:
1210+
strategy_keyed_chain = body_.pop("strategy")
1211+
1212+
# Convert KeyedChain to GufeTokenizable if strategy is provided
1213+
if strategy_keyed_chain is not None:
1214+
strategy_kc = KeyedChain(strategy_keyed_chain)
1215+
strategy = strategy_kc.to_gufe()
1216+
else:
1217+
strategy = None
1218+
except Exception as e:
1219+
raise HTTPException(
1220+
status_code=http_status.HTTP_422_UNPROCESSABLE_ENTITY,
1221+
detail=str(e),
1222+
)
1223+
1224+
if strategy is not None:
1225+
# Create strategy state from body parameters
1226+
try:
1227+
strategy_state = StrategyState(**body_)
1228+
except ValidationError as e:
1229+
raise HTTPException(
1230+
status_code=http_status.HTTP_422_UNPROCESSABLE_ENTITY,
1231+
detail=str(e),
1232+
)
1233+
1234+
try:
1235+
strategy_sk = n4js.set_network_strategy(sk, strategy, strategy_state)
1236+
except ValueError:
1237+
raise HTTPException(
1238+
status_code=http_status.HTTP_400_BAD_REQUEST,
1239+
detail=str(e),
1240+
)
1241+
1242+
return str(strategy_sk) if strategy_sk is not None else None
1243+
else:
1244+
# Remove strategy
1245+
n4js.set_network_strategy(sk, None)
1246+
return None
1247+
1248+
1249+
@router.get("/networks/{network_scoped_key}/strategy")
1250+
def get_network_strategy(
1251+
network_scoped_key: str,
1252+
*,
1253+
n4js: Neo4jStore = Depends(get_n4js_depends),
1254+
token: TokenData = Depends(get_token_data_depends),
1255+
):
1256+
"""Get the Strategy for the given AlchemicalNetwork."""
1257+
sk = ScopedKey.from_str(network_scoped_key)
1258+
validate_scopes(sk.scope, token)
1259+
1260+
strategy = n4js.get_network_strategy(sk)
1261+
return GufeJSONResponse(strategy) if strategy is not None else None
1262+
1263+
1264+
@router.get("/networks/{network_scoped_key}/strategy/state")
1265+
def get_network_strategy_state(
1266+
network_scoped_key: str,
1267+
*,
1268+
n4js: Neo4jStore = Depends(get_n4js_depends),
1269+
token: TokenData = Depends(get_token_data_depends),
1270+
):
1271+
"""Get the StrategyState for the given AlchemicalNetwork."""
1272+
sk = ScopedKey.from_str(network_scoped_key)
1273+
validate_scopes(sk.scope, token)
1274+
1275+
strategy_state = n4js.get_network_strategy_state(sk)
1276+
1277+
return strategy_state.to_dict() if strategy_state is not None else None
1278+
1279+
1280+
@router.get("/networks/{network_scoped_key}/strategy/status")
1281+
def get_network_strategy_status(
1282+
network_scoped_key: str,
1283+
*,
1284+
n4js: Neo4jStore = Depends(get_n4js_depends),
1285+
token: TokenData = Depends(get_token_data_depends),
1286+
):
1287+
"""Get the status of the Strategy for the given AlchemicalNetwork."""
1288+
sk = ScopedKey.from_str(network_scoped_key)
1289+
validate_scopes(sk.scope, token)
1290+
1291+
strategy_state = n4js.get_network_strategy_state(sk)
1292+
1293+
return strategy_state.status.value if strategy_state is not None else None
1294+
1295+
1296+
@router.post("/networks/{network_scoped_key}/strategy/awake")
1297+
def set_network_strategy_awake(
1298+
network_scoped_key: str,
1299+
*,
1300+
n4js: Neo4jStore = Depends(get_n4js_depends),
1301+
token: TokenData = Depends(get_token_data_depends),
1302+
):
1303+
"""Set the Strategy status to 'awake' for the given AlchemicalNetwork."""
1304+
sk = ScopedKey.from_str(network_scoped_key)
1305+
validate_scopes(sk.scope, token)
1306+
1307+
strategy_state = n4js.get_network_strategy_state(sk)
1308+
1309+
if strategy_state is None:
1310+
return
1311+
1312+
# Update strategy state to awake and clear error info
1313+
strategy_state.status = "awake"
1314+
strategy_state.exception = None
1315+
strategy_state.traceback = None
1316+
1317+
updated = n4js.update_strategy_state(sk, strategy_state)
1318+
1319+
return str(updated) if updated is not None else None
1320+
1321+
11831322
### add router
11841323

11851324
app.include_router(router)

alchemiscale/interface/client.py

Lines changed: 125 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,16 @@
2121
from ..base.client import (
2222
AlchemiscaleBaseClient,
2323
AlchemiscaleBaseClientError,
24-
json_to_gufe,
2524
use_session,
2625
)
27-
from ..compression import decompress_gufe_zstd, compress_keyed_chain_zstd
26+
from ..compression import decompress_gufe_zstd, compress_keyed_chain_zstd, json_to_gufe
2827
from ..models import Scope, ScopedKey
2928
from ..storage.models import (
3029
TaskStatusEnum,
3130
NetworkStateEnum,
31+
StrategyState,
3232
)
33-
from ..strategies import Strategy
33+
from stratocaster.base import Strategy
3434
from ..validators import validate_network_nonself
3535

3636
from warnings import warn
@@ -690,14 +690,131 @@ def _get_chemicalsystem():
690690

691691
### compute
692692

693-
def set_strategy(self, network: ScopedKey, strategy: Strategy):
694-
"""Set the Strategy for evaluating the given AlchemicalNetwork.
693+
#### strategies
695694

696-
The Strategy will be applied to create and action tasks for the
697-
Transformations in the AlchemicalNetwork without user interaction.
695+
def set_network_strategy(
696+
self,
697+
network: ScopedKey,
698+
strategy: GufeTokenizable | None,
699+
max_tasks_per_transformation: int = 3,
700+
task_scaling: str = "exponential",
701+
mode: str = "partial",
702+
sleep_interval: int = 3600,
703+
) -> ScopedKey | None:
704+
"""Set a Strategy for the given AlchemicalNetwork.
705+
706+
Parameters
707+
----------
708+
network
709+
ScopedKey of the AlchemicalNetwork.
710+
strategy
711+
Strategy object (GufeTokenizable) or None to remove strategy.
712+
max_tasks_per_transformation.
713+
Maximum number of actioned Tasks allowed on a Transformation at once.
714+
task_scaling
715+
How to translate weights into Task counts: "linear" or "exponential".
716+
mode
717+
Strategy mode: "full", "partial", or "disabled".
718+
sleep_interval
719+
Wait time between iterations of the Strategy in seconds.
720+
721+
Returns
722+
-------
723+
ScopedKey | None
724+
ScopedKey of the Strategy that was set, or ``None`` if strategy was
725+
removed.
726+
"""
727+
if strategy is not None:
728+
# Convert strategy to keyed chain for serialization
729+
strategy_keyed_chain = KeyedChain.gufe_to_keyed_chain_rep(strategy)
730+
data = {
731+
"strategy": strategy_keyed_chain,
732+
"max_tasks_per_transformation": max_tasks_per_transformation,
733+
"task_scaling": task_scaling,
734+
"mode": mode,
735+
"sleep_interval": sleep_interval,
736+
}
737+
else:
738+
# Remove strategy
739+
data = {"strategy": None}
740+
741+
result = self._post_resource(f"/networks/{network}/strategy", data)
742+
return ScopedKey.from_str(result) if result else None
743+
744+
def get_network_strategy(self, network: ScopedKey) -> Strategy | None:
745+
"""Get the Strategy for the given AlchemicalNetwork.
746+
747+
Parameters
748+
----------
749+
network
750+
ScopedKey of the AlchemicalNetwork.
751+
752+
Returns
753+
-------
754+
strategy
755+
Strategy object for this AlchemicalNetwork; ``None`` if no Strategy
756+
is set.
757+
758+
"""
759+
keyed_chain = self._get_resource(f"/networks/{network}/strategy")
760+
return GufeTokenizable.from_keyed_chain(keyed_chain) if keyed_chain else None
761+
762+
def get_network_strategy_state(self, network: ScopedKey) -> StrategyState | None:
763+
"""Get the StrategyState for the given AlchemicalNetwork.
764+
765+
Parameters
766+
----------
767+
network
768+
ScopedKey of the AlchemicalNetwork.
769+
770+
Returns
771+
-------
772+
strategy_state
773+
Strategy state with execution metadata; ``None`` if no Strategy is
774+
set.
698775
699776
"""
700-
raise NotImplementedError
777+
state_dict = self._get_resource(f"/networks/{network}/strategy/state")
778+
return StrategyState.from_dict(state_dict) if state_dict else None
779+
780+
def get_network_strategy_status(self, network: ScopedKey) -> str | None:
781+
"""Get the status of the Strategy for the given AlchemicalNetwork.
782+
783+
Parameters
784+
----------
785+
network
786+
ScopedKey of the AlchemicalNetwork.
787+
788+
Returns
789+
-------
790+
status
791+
Strategy status: "awake", "dormant", or "error"; ``None`` if no
792+
Strategy is set.
793+
794+
"""
795+
return self._get_resource(f"/networks/{network}/strategy/status")
796+
797+
def set_network_strategy_awake(self, network: ScopedKey) -> ScopedKey | None:
798+
"""Set the Strategy status to 'awake' for the given AlchemicalNetwork.
799+
800+
This resets a dormant or errored strategy to active status.
801+
802+
Parameters
803+
----------
804+
network
805+
ScopedKey of the AlchemicalNetwork to set Strategy 'awake' for.
806+
807+
Returns
808+
-------
809+
ScopedKey | None
810+
ScopedKey of the AlchemicalNetwork if Strategy status set to
811+
'awake'; ``None`` otherwise.
812+
813+
"""
814+
result = self._post_resource(f"/networks/{network}/strategy/awake", {})
815+
return ScopedKey.from_str(result) if result is not None else None
816+
817+
#### tasks
701818

702819
def create_tasks(
703820
self,

0 commit comments

Comments
 (0)