Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bottom-bar-panel-clearance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Keep the bottom toolbar clear of the side panels and the viewport controls: it stays centered on the canvas until a panel would cover it, then shifts aside, and collapses to its essential controls — expanding again on hover or focus — when the space between the panels is too narrow for the full set.
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ jobs:
push: ["ecr", "ghcr"],
dockerfile: "apps/petrinaut-opt/docker/Dockerfile",
context: ".",
paths: ["apps/petrinaut-opt", "libs/@hashintel/petrinaut-cli", "libs/@hashintel/petrinaut-core", "libs/@local/petrinaut-python"],
paths: ["apps/petrinaut-opt", "libs/@hashintel/petrinaut-cli", "libs/@hashintel/petrinaut-core", "libs/@local/petrinaut-optimizer-core", "libs/@local/petrinaut-python"],
ecs: [{ service: "petrinaut-opt", cluster: "h-stage-euc1-app", service_name: "h-stage-euc1-app-petrinaut-opt" }]
},
{
Expand Down
2 changes: 1 addition & 1 deletion apps/petrinaut-opt/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Python service running Optuna optimization studies over Petrinaut simulations.

- Depends on `@local/petrinaut-python` only; nothing in the service references `petrinaut-cli` directly.
- Depends on `@local/petrinaut-python` and `@local/petrinaut-optimizer-core`; nothing in the service references `petrinaut-cli` directly. Study construction, suggestion and the trial cap live in the core, shared with the in-browser optimizer; the service adds the HTTP API, the worker thread and telemetry.
- Experiment and optimization code stays pure: the host owns worker counts, threads, and other OS concerns.
- Run tests with `uv run pytest` from this directory.

Expand Down
6 changes: 5 additions & 1 deletion apps/petrinaut-opt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,11 @@ flat parameters that are not fixed, each one a descriptor such as:
```

`float`, `int`, and `boolean` map onto `suggest_float`, `suggest_int`, and
`suggest_categorical`, and the study seed seeds the sampler. The bindings'
`suggest_categorical`, and the study seed seeds the sampler. That mapping, the
description's cross-field rules, and the seeded study construction come from
[`@local/petrinaut-optimizer-core`](../../libs/@local/petrinaut-optimizer-core/README.md),
which the in-browser optimizer runs under Pyodide, so a study proposes the same
values in both places. The bindings'
[usage manual](../../libs/@local/petrinaut-python/README.md) documents the full
response.

Expand Down
8 changes: 5 additions & 3 deletions apps/petrinaut-opt/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,12 @@ WORKDIR /repo/apps/petrinaut-opt
COPY --from=uv /uv /usr/local/bin/uv

# Installs dependencies before the service's own source, so editing `src/`
# reuses this layer. Editing the bindings does rebuild it, since they are part
# of the environment. `--no-editable` builds them into the virtualenv, so the
# runner needs the virtualenv alone and no source tree at a matching path.
# reuses this layer. Editing the bindings or the optimizer core does rebuild
# it, since they are part of the environment. `--no-editable` builds them into
# the virtualenv, so the runner needs the virtualenv alone and no source tree
# at a matching path.
COPY apps/petrinaut-opt/pyproject.toml apps/petrinaut-opt/uv.lock ./
COPY libs/@local/petrinaut-optimizer-core /repo/libs/@local/petrinaut-optimizer-core
COPY libs/@local/petrinaut-python /repo/libs/@local/petrinaut-python
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-install-project --no-editable
Expand Down
1 change: 1 addition & 0 deletions apps/petrinaut-opt/docs/task-dependencies.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"package": "@apps/petrinaut-opt",
"dependencies": [
"@local/petrinaut-optimizer-core",
"@local/petrinaut-python"
],
"tasks": {
Expand Down
1 change: 1 addition & 0 deletions apps/petrinaut-opt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"test:unit": "uv run pytest && yarn codegen && git diff --exit-code -- openapi/openapi.json"
},
"dependencies": {
"@local/petrinaut-optimizer-core": "workspace:*",
"@local/petrinaut-python": "workspace:*"
}
}
6 changes: 4 additions & 2 deletions apps/petrinaut-opt/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,17 @@ dependencies = [
"opentelemetry-exporter-otlp-proto-http>=1.44.0",
"opentelemetry-instrumentation-fastapi>=0.65b0",
"opentelemetry-sdk>=1.44.0",
"optuna>=3.6",
"optuna>=4.9,<5",
"petrinaut-optimizer-core",
"petrinaut-python",
"pydantic>=2.13.4",
"python-dotenv>=1.0.0",
"uvicorn[standard]>=0.39.0",
]

[tool.uv.sources]
petrinaut-python = { path = "../../libs/@local/petrinaut-python", editable = true }
petrinaut-optimizer-core = { path = "../../libs/@local/petrinaut-optimizer-core", editable = true }
petrinaut-python = { path = "../../libs/@local/petrinaut-python", editable = true }

[dependency-groups]
dev = [
Expand Down
131 changes: 12 additions & 119 deletions apps/petrinaut-opt/src/petrinaut_optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,15 @@
import threading
from collections.abc import Callable, Mapping
from contextlib import suppress
from datetime import datetime, timezone
from typing import Any, Literal, TypeAlias, cast
from typing import Any, cast

import optuna
import petrinaut_optimizer_core as optimizer_core
from opentelemetry import context as otel_context
from opentelemetry import trace
from opentelemetry.trace import Span, Status, StatusCode
from petrinaut import (
OptimizationBooleanParameter,
OptimizationDescribeResult,
OptimizationFloatParameter,
OptimizationIntParameter,
OptimizationSession,
PetrinautRunError,
)
Expand All @@ -31,28 +28,17 @@

log = logging.getLogger("pn_optimize")
tracer = trace.get_tracer("pn_optimize")
optuna.logging.set_verbosity(optuna.logging.WARNING)

SAMPLERS = {
"tpe": optuna.samplers.TPESampler,
"random": optuna.samplers.RandomSampler,
}
DEFAULT_STUDY_NAME = "opt_study"
# The service-side mirror of the optimization manifest's trial cap; it also
# The optimization manifest's trial cap, mirrored by the shared core; it also
# bounds every run's in-memory event log to one frame per trial plus a
# handful of control frames, even against a study reporting a huge trial count.
MAX_STUDY_TRIALS = 1000
MAX_STUDY_TRIALS = optimizer_core.MAX_STUDY_TRIALS
MAX_STUDY_SECONDS_ENVIRONMENT_VARIABLE = "HASH_PETRINAUT_OPT_MAX_STUDY_SECONDS"
DEFAULT_MAX_STUDY_SECONDS = 900.0
_DISCONNECT_POLL_SECONDS = 0.1
_WORKER_SHUTDOWN_TIMEOUT_SECONDS = 12
_SENTINEL = object()

Scalar: TypeAlias = int | float | bool
ParameterDescriptor: TypeAlias = (
OptimizationFloatParameter | OptimizationIntParameter | OptimizationBooleanParameter
)


def max_study_seconds_from_environment() -> float:
"""Read the detached-study wall-clock ceiling in seconds.
Expand All @@ -73,67 +59,6 @@ def max_study_seconds_from_environment() -> float:
return value


def _parse_description(
description: OptimizationDescribeResult,
) -> tuple[
Literal["maximize", "minimize"],
str,
int,
int,
tuple[ParameterDescriptor, ...],
]:
"""Check the semantic rules the protocol schema cannot express.

The shape is already proven: the bindings validate every describe result
against the CLI's published schema before this sees it. What remains are
cross-field rules — bound ordering, log-scale domains, duplicates — and
this service's own study limits.
"""
sampler = description.study.sampler.value
if sampler not in SAMPLERS:
raise ValueError(f"unsupported Optuna sampler: {sampler!r}")
n_trials = description.study.trials
if n_trials > MAX_STUDY_TRIALS:
raise ValueError(
f"optimization.describe study.trials must not exceed {MAX_STUDY_TRIALS}"
)
seed = description.study.seed
if seed < 0:
raise ValueError(
"optimization.describe study.seed must be a non-negative integer"
)

identifiers: set[str] = set()
for parameter in description.parameters:
identifier = parameter.identifier
if identifier in identifiers:
raise ValueError(f'duplicate optimization parameter "{identifier}"')
identifiers.add(identifier)

if isinstance(parameter, OptimizationBooleanParameter):
continue
if not math.isfinite(parameter.minimum) or not math.isfinite(parameter.maximum):
raise ValueError(f"{identifier} bounds must be finite numbers")
if parameter.minimum >= parameter.maximum:
raise ValueError(f"{identifier}.maximum must exceed minimum")
if parameter.scale.value == "log" and parameter.minimum <= 0:
raise ValueError(f"{identifier}.minimum must be positive for log scale")
if (
isinstance(parameter, OptimizationIntParameter)
and parameter.scale.value == "log"
and parameter.step != 1
):
raise ValueError(f"{identifier}.step must be 1 for log scale")

return (
description.direction.value,
sampler,
n_trials,
seed,
tuple(description.parameters),
)


class PetrinautOptimizer:
"""Optimize the flat parameter descriptors the bindings report."""

Expand All @@ -152,51 +77,19 @@ def __init__(
if isinstance(raw, OptimizationDescribeResult)
else OptimizationDescribeResult.model_validate(raw)
)
direction, sampler_name, n_trials, seed, parameters = _parse_description(
described
)

self.parameters = parameters
self.study_name = f"{DEFAULT_STUDY_NAME}_{datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%S')}"
sampler_options.setdefault("seed", seed)
self.sampler = SAMPLERS[sampler_name](**sampler_options)
self.direction = direction
self.n_trials = n_trials
self.study = optuna.create_study(
study_name=self.study_name,
storage=None,
load_if_exists=False,
direction=self.direction,
sampler=self.sampler,
self.description = optimizer_core.parse_description(
described.model_dump(mode="json")
)
self.parameters = self.description.parameters
self.direction = self.description.direction
self.n_trials = self.description.trials
self.study = optimizer_core.create_study(self.description, **sampler_options)
self.pn_model = pn_model
self.lock = threading.Lock()

def suggest(self, trial: optuna.Trial) -> dict[str, Scalar]:
def suggest(self, trial: optuna.Trial) -> dict[str, optimizer_core.Scalar]:
"""Ask Optuna for each non-fixed scenario parameter the study describes."""
values: dict[str, Scalar] = {}
for parameter in self.parameters:
identifier = parameter.identifier
if isinstance(parameter, OptimizationFloatParameter):
values[identifier] = trial.suggest_float(
identifier,
parameter.minimum,
parameter.maximum,
log=parameter.scale.value == "log",
)
elif isinstance(parameter, OptimizationIntParameter):
values[identifier] = trial.suggest_int(
identifier,
int(parameter.minimum),
int(parameter.maximum),
step=int(parameter.step),
log=parameter.scale.value == "log",
)
else:
values[identifier] = trial.suggest_categorical(
identifier, [False, True]
)
return values
return optimizer_core.suggest(trial, self.parameters)

def objective(self, trial: optuna.Trial) -> float:
"""Propose one flat parameter set and ask Petrinaut to evaluate it."""
Expand Down
67 changes: 2 additions & 65 deletions apps/petrinaut-opt/tests/test_petrinaut_optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,30 +64,6 @@ def objective(self, parameter_values: dict[str, Any]) -> float:
raise PetrinautClientError("session closed")


def test_maps_float_integer_step_and_boolean_descriptors_to_optuna(
optimization_description: dict,
) -> None:
model = FakeModel(optimization_description)
optimizer = PetrinautOptimizer(model) # type: ignore[arg-type]
trial = optuna.trial.FixedTrial({"rate": 0.5, "count": 6, "enabled": False})

assert optimizer.suggest(trial) == {
"rate": 0.5,
"count": 6,
"enabled": False,
}
distributions = trial.distributions
assert isinstance(distributions["rate"], optuna.distributions.FloatDistribution)
assert distributions["rate"].log is True
assert isinstance(distributions["count"], optuna.distributions.IntDistribution)
assert distributions["count"].step == 2
assert distributions["count"].log is False
assert isinstance(
distributions["enabled"], optuna.distributions.CategoricalDistribution
)
assert distributions["enabled"].choices == (False, True)


def test_objective_sends_only_flat_suggested_values(
optimization_description: dict,
) -> None:
Expand Down Expand Up @@ -134,58 +110,19 @@ def test_objective_propagates_transport_errors(
optimizer.objective(trial)


def test_uses_the_session_supplied_seed_for_deterministic_sampling(
optimization_description: dict,
) -> None:
first = PetrinautOptimizer( # type: ignore[arg-type]
FakeModel(optimization_description)
)
second = PetrinautOptimizer( # type: ignore[arg-type]
FakeModel(optimization_description)
)

assert first.suggest(first.study.ask()) == second.suggest(second.study.ask())


@pytest.mark.parametrize(
"change",
[
# A shape the protocol schema rejects before the shared core sees it.
{"direction": "up"},
{"study": {"trials": 0, "sampler": "random", "seed": 42}},
# The service-side trial cap bounds every run's event log even when
# the reported study is huge.
# A rule the shared core enforces; its own suite covers the full matrix.
{
"study": {
"trials": petrinaut_optimizer.MAX_STUDY_TRIALS + 1,
"sampler": "random",
"seed": 42,
}
},
{"study": {"trials": 1, "sampler": "unknown", "seed": 42}},
{"study": {"trials": 1, "sampler": "random", "seed": -1}},
{
"parameters": [
{
"identifier": "rate",
"type": "float",
"minimum": 0,
"maximum": 1,
"scale": "log",
}
]
},
{
"parameters": [
{
"identifier": "count",
"type": "int",
"minimum": 1,
"maximum": 10,
"step": 2,
"scale": "log",
}
]
},
],
)
def test_rejects_invalid_session_descriptions(
Expand Down
1 change: 1 addition & 0 deletions apps/petrinaut-opt/turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"uv.lock",
"scripts/generate_openapi.py",
"src/**/*.py",
"../../libs/@local/petrinaut-optimizer-core/src/**/*.py",
"../../libs/@local/petrinaut-python/src/**/*.py"
],
"outputs": ["openapi/openapi.json"],
Expand Down
Loading
Loading